Source code for cpmpy.tools.io.scip_formats

#!/usr/bin/env python
#-*- coding:utf-8 -*-
##
## scip.py
##
"""
This file implements helper functions for converting CPMpy models to and from various data 
formats supported by the SCIP optimization suite.

============
Installation
============

The 'pyscipopt' optional dependency must be installed separately through `pip`:

.. code-block:: console
    
    $ pip install cpmpy[io.scip]

=================
List of functions
=================

.. autosummary::
    :nosignatures:

    load_scip_format
    write_scip_format
"""


import math
import os
import tempfile
from io import TextIOBase
import cpmpy as cp
import warnings
import builtins
from typing import Union, Optional, Callable, TYPE_CHECKING, TextIO
from functools import partial

if TYPE_CHECKING:
    import pyscipopt

from cpmpy.solvers.scip import CPM_scip
from cpmpy.expressions.variables import _ignore_strict_variable_name_check
from cpmpy.model import _update_variable_counters
from cpmpy.tools.io.utils import _create_header, _derive_format, get_extension


[docs] def load_scip_format(instance: Union[str, os.PathLike, TextIO], open:Callable = builtins.open, assume_integer:bool=False, type: Optional[str]=None) -> cp.Model: """ Load a SCIP-compatible model from a file and return a CPMpy model. Arguments: instance (str or os.PathLike or TextIO): The path to the SCIP-compatible file to read, a string containing the model content directly, or a TextIO object already open for reading. open (Callable): The function to use to open the file. (SCIP does not require this argument, will be ignored) assume_integer (bool): Whether to assume that all variables are integer. type (str, optional): Type of the inline string or unnamed TextIO input. Required when ``instance`` is a raw string. Warning: Setting assumes_integer to True will cause CPMpy to assume that all variables are integer, even if they are not explicitly declared as such in the problem file. Use with caution. Returns: cp.Model: A CPMpy model. """ # Check if SCIP is installed if not _SCIPWriter.supported(): raise Exception("SCIP: Install SCIP IO dependencies: cpmpy[io.scip]") from pyscipopt import Model content = None if isinstance(instance, TextIOBase): content = instance.read() if type is None: stream_name = getattr(instance, "name", None) if not isinstance(stream_name, (str, os.PathLike)): raise ValueError("Type must be provided when loading a SCIP model from an unnamed TextIO object.") type = _derive_format(stream_name) elif isinstance(instance, str) and not os.path.exists(instance): if type is None: raise ValueError("Type must be provided when loading a SCIP model from a string.") content = instance # SCIP's parser only supports file paths # -> write content to a temporary file tmp_fname = None if content is not None: assert type is not None try: suffix = "." + get_extension(type) except KeyError as e: raise ValueError(f"Unsupported SCIP file type: {type}") from e with tempfile.NamedTemporaryFile(suffix=suffix, mode="w", delete=False) as tmp: tmp.write(content) tmp_fname = tmp.name instance = tmp_fname # Load file into pyscipopt model scip = Model() try: scip.hideOutput() # suppress SCIP output scip.readProblem(filename=instance) scip.hideOutput(quiet=False) finally: if tmp_fname is not None: os.remove(tmp_fname) # 1) translate variables scip_vars = scip.getVars() var_map = {} with _ignore_strict_variable_name_check(): for var in scip_vars: name = var.name # name of the variable vtype = var.vtype() # type of the variable if vtype == "BINARY": var_map[name] = cp.boolvar(name=name) elif vtype == "INTEGER": lb = int(var.getLbOriginal()) ub = int(var.getUbOriginal()) var_map[name] = cp.intvar(lb, ub, name=name) elif vtype == "CONTINUOUS": if assume_integer: lb = int(math.ceil(var.getLbOriginal())) ub = int(math.floor(var.getUbOriginal())) if lb != var.getLbOriginal() or ub != var.getUbOriginal(): warnings.warn(f"Continuous variable {name} has non-integer bounds {var.getLbOriginal()} - {var.getUbOriginal()}. CPMpy will assume it is integer.") var_map[name] = cp.intvar(lb, ub, name=name) else: raise ValueError(f"CPMpy does not support continious variables: {name}") else: raise ValueError(f"Unsupported variable type: {vtype}") model = cp.Model() # 2) translate constraints scip_cons = scip.getConss() for cons in scip_cons: ctype = cons.getConshdlrName() # type of the constraint if ctype == "linear": cons_vars = scip.getConsVars(cons) # variables in the constraint (x) cons_coeff = scip.getConsVals(cons) # coefficients of the variables (A) cpm_vars = [var_map[v.name] for v in cons_vars] # convert to CPMpy variables cpm_sum = cp.sum(var*coeff for (var,coeff) in zip(cpm_vars, cons_coeff)) # Ax lhs = scip.getLhs(cons) # lower bound of the constraint (-infinity if one-sided) rhs = scip.getRhs(cons) # upper bound of the constraint (+infinity if one-sided) # SCIP encodes an open side with +/- infinity (a large sentinel, e.g. 1e20). # Such a side is not a real bound, so it must not be turned into a constraint # (materialising it would also overflow solver integer limits). has_lhs = not scip.isInfinity(-lhs) has_rhs = not scip.isInfinity(rhs) def _check_integer(bound, rounded): if rounded != bound: if assume_integer: warnings.warn(f"Constraint {cons.name} has non-integer bounds. CPMpy will assume it is integer.") else: raise ValueError(f"Constraint {cons.name} has non-integer bounds. CPMpy does not support non-integer bounds.") # add the (finite) constraint bound(s) to the model if has_lhs: _lhs = math.ceil(lhs) _check_integer(lhs, _lhs) model += int(_lhs) <= cpm_sum if has_rhs: _rhs = math.floor(rhs) _check_integer(rhs, _rhs) model += cpm_sum <= int(_rhs) else: raise ValueError(f"Unsupported constraint type: {ctype}") # 3) translate objective scip_objective = scip.getObjective() direction = scip.getObjectiveSense() objective = _load_scip_objective(scip_objective, var_map, assume_integer) if direction == "minimize": model.minimize(objective) elif direction == "maximize": model.maximize(objective) else: raise ValueError(f"Unsupported objective sense: {direction}") _update_variable_counters(model) return model
def _load_scip_objective(scip_objective, var_map, assume_integer: bool): """ Translate a SCIP objective to a CPMpy objective. Arguments: scip_objective: The SCIP objective to translate. var_map: A dictionary mapping SCIP variable names to CPMpy variables. assume_integer: Whether to assume that all variables are integer. Returns: The CPMpy objective. Raises: ValueError: If the objective term has a non-integer coefficient and assume_integer is False. """ obj_terms = [] for term, coeff in scip_objective.terms.items(): # terms is a dictionary mapping terms to coefficients _coeff = int(math.floor(coeff)) if _coeff != int(coeff): if assume_integer: warnings.warn(f"Objective term {term} has non-integer coefficient. CPMpy will assume it is integer.") else: raise ValueError(f"Objective term {term} has non-integer coefficient. CPMpy does not support non-integer coefficients.") cpm_term = 1 for scip_var in term.vartuple: cpm_term *= var_map[scip_var.name] obj_terms.append(_coeff * cpm_term) return cp.sum(obj_terms) class _SCIPWriter(CPM_scip): """ A helper class aiding in translating CPMpy models to SCIP models. Builds on top of the CPMpy SCIP solver interface. """ def __init__(self, model: cp.Model, problem_name: Optional[str] = None): if not self.supported(): raise Exception( "SCIP: Install SCIP IO dependencies: cpmpy[io.scip]") super().__init__(model) self.scip_model.setProbName(problem_name) @staticmethod def _add_header(path: Union[str, os.PathLike], format: str, header: Optional[str] = None): """ Add a header to a file. Arguments: path (str or os.PathLike): The path to the file to add the header to. format (str): The format of the file. header (Optional[str]): The header to add. """ if header is None: header = "" with open(path, "r") as f: lines = f.readlines() if format == "mps": header_lines = ["* " + line + "\n" for line in header.splitlines()] lines = header_lines + lines elif format == "lp": header_lines = ["\\ " + line + "\n" for line in header.splitlines()] lines = header_lines + lines elif format == "cip": header_lines = ["# " + line + "\n" for line in header.splitlines()] lines = header_lines + lines elif format == "fzn": header_lines = ["% " + line + "\n" for line in header.splitlines()] lines = header_lines + lines elif format == "gms": header_lines = ["* " + line + "\n" for line in header.splitlines()] lines = [lines[0]] + header_lines + lines[1:] # handle first line: $OFFLISTING elif format == "pip": header_lines = ["\\ " + line + "\n" for line in header.splitlines()] lines = header_lines + lines else: warnings.warn(f"Unsupported format for header: {format}") return with open(path, "w") as f: f.writelines(lines)
[docs] def write_scip_format( model: cp.Model, path: Optional[Union[str, os.PathLike]] = None, format: str = "mps", header: Optional[str] = None, verbose: bool = False, open: Callable = partial(builtins.open, mode="w") ) -> str: """ Write a CPMpy model to file using the SCIP solver. Supported formats include: - "mps" - "lp" - "cip" - "fzn" - "gms" - "pip" More formats can be supported upon the installation of additional dependencies (like SIMPL). For more information, see the SCIP documentation: https://pyscipopt.readthedocs.io/en/latest/tutorials/readwrite.html Arguments: model (cp.Model): CPMpy model to write. path (str or os.PathLike, optional): The file path to write the SCIP output to. If None, the SCIP string is returned. format (str): Output format (e.g. "mps", "lp", "cip", "fzn", "gms", "pip"). header (Optional[str]): Optional header text to prepend (format-dependent comment style). If None, a default CPMpy header is created only when writing to ``path``. Pass an empty string to skip adding a header. verbose (bool): If True, allow SCIP to print progress. open (Callable): Callable to open the file for writing (default: builtin ``open``). Called as ``open(path)``. Mirrors the ``open=`` argument in loaders and allows custom compression or I/O (e.g. ``lambda p: lzma.open(p, 'wt')``). Returns: str: The file content as a string (whether written to ``path`` or not). """ writer = _SCIPWriter(model, problem_name="CPMpy Model") if header is None: header = _create_header(format=format) if path is not None else None elif header == "": header = None # Always write via SCIP to a temp file, then add header and get content with tempfile.NamedTemporaryFile(suffix=f".{format}", delete=False) as tmp: tmp_fname = tmp.name try: if not verbose: writer.scip_model.hideOutput() devnull = os.open(os.devnull, os.O_WRONLY) old_stdout = os.dup(1) os.dup2(devnull, 1) try: writer.scip_model.writeProblem(tmp_fname, verbose=verbose) finally: os.dup2(old_stdout, 1) os.close(devnull) os.close(old_stdout) if not verbose: writer.scip_model.hideOutput(quiet=False) writer._add_header(tmp_fname, format, header) with builtins.open(tmp_fname, "r") as f: content = f.read() if path is not None: with open(path) as f: f.write(content) return content finally: os.remove(tmp_fname)