diff --git a/.github/workflows/github-ci.yml b/.github/workflows/github-ci.yml index 96ea6da1..ecceee22 100644 --- a/.github/workflows/github-ci.yml +++ b/.github/workflows/github-ci.yml @@ -2,6 +2,10 @@ name: CI on: [push, pull_request] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: pre-commit: diff --git a/mip/cbc.py b/mip/cbc.py index cf777d34..3d25d661 100644 --- a/mip/cbc.py +++ b/mip/cbc.py @@ -70,36 +70,20 @@ cbclib = ffi.dlopen(libfile) os.chdir(old_dir) else: - import cbcbox as _cbcbox - - _lib_dir = _cbcbox.cbc_lib_dir() - if "linux" in platform.lower(): - if not os_is_64_bit: - raise NotImplementedError("Linux 32 bits platform not supported.") - libfile = os.path.join(_lib_dir, "libCbc.so") - elif platform.lower().startswith("win"): - if not os_is_64_bit: - raise NotImplementedError("Win32 platform not supported.") - # autotools/MinGW places DLLs under bin/, not lib/ - _bin_dir = os.path.join(_cbcbox.cbc_dist_dir(), "bin") - libfile = os.path.join(_bin_dir, "libCbc-0.dll") - if not os.path.exists(libfile): - raise FileNotFoundError( - "libCbc-0.dll not found in cbcbox Windows distribution at" - " {}. The cbcbox Windows wheel may only contain a static" - " libCbc.a. A shared libCbc-0.dll is required.".format(_bin_dir) - ) - # Python 3.8+ ignores PATH for DLL resolution; use add_dll_directory + import mipster as _mipster + + if not os_is_64_bit: + raise NotImplementedError("32-bit platforms are not supported.") + libfile = _mipster.lib_path() + if platform.lower().startswith("win"): + # Python 3.8+ ignores PATH for DLL resolution; let Windows find + # any sibling DLLs (libgcc, libstdc++, libwinpthread) via + # add_dll_directory on the directory holding libmipster-N.dll. + _bin_dir = os.path.dirname(libfile) if hasattr(os, "add_dll_directory"): os.add_dll_directory(_bin_dir) elif _bin_dir not in os.environ.get("PATH", ""): os.environ["PATH"] = _bin_dir + ";" + os.environ["PATH"] - elif platform.lower().startswith("darwin") or platform.lower().startswith( - "macos" - ): - libfile = os.path.join(_lib_dir, "libCbc.dylib") - else: - raise NotImplementedError("Your operating system/platform is not supported") cbclib = ffi.dlopen(libfile) has_cbc = True except Exception as e: @@ -317,11 +301,6 @@ void Cbc_setAllowableFractionGap(Cbc_Model *model, double allowedFracionGap); - double Cbc_getAllowablePercentageGap(Cbc_Model *model); - - void Cbc_setAllowablePercentageGap(Cbc_Model *model, - double allowedPercentageGap); - double Cbc_getMaximumSeconds(Cbc_Model *model); void Cbc_setMaximumSeconds(Cbc_Model *model, double maxSeconds); @@ -1022,7 +1001,9 @@ def clique_merge(self, constrs: Optional[List["mip.Constr"]] = None): strengthenPacking = cbclib.Cbc_strengthenPackingRows strengthenPacking(self._model, nr, idxr) - def optimize(self, relax: bool = False, lp_preprocess: bool = False) -> OptimizationStatus: + def optimize( + self, relax: bool = False, lp_preprocess: bool = False + ) -> OptimizationStatus: # get name indexes from an osi problem def cbc_get_osi_name_indexes(osi_solver) -> Dict[str, int]: nameIdx = {} @@ -1203,7 +1184,9 @@ def cbc_cut_callback(osi_solver, osi_cuts, app_data, depth, npass): # user-specified cut passes override the cuts-level default if self.model.cut_passes != -1: - cbclib.Cbc_setIntParam(self._model, INT_PARAM_CUT_PASS, self.model.cut_passes) + cbclib.Cbc_setIntParam( + self._model, INT_PARAM_CUT_PASS, self.model.cut_passes + ) if self.model.clique == 0: cbc_set_parameter(self, "clique", "off") @@ -1662,7 +1645,8 @@ def remove_vars(self, varsList: List[int]): cbclib.Cbc_deleteCols(self._model, len(varsList), idx) def __del__(self): - cbclib.Cbc_deleteModel(self._model) + if getattr(self, "_model", None) is not None: + cbclib.Cbc_deleteModel(self._model) def get_problem_name(self) -> str: namep = self.__name_space @@ -1793,7 +1777,7 @@ def __clear_sol(self: "SolverOsi"): self.__obj_val = None def __del__(self): - if self.owns_solver: + if getattr(self, "owns_solver", False): cbclib.Osi_deleteSolver(self.osi) def add_var( diff --git a/mip/gurobi.py b/mip/gurobi.py index fa3a8c43..be2f8b34 100644 --- a/mip/gurobi.py +++ b/mip/gurobi.py @@ -428,11 +428,12 @@ def __clear_sol(self): self.__obj_val = None def __del__(self): - # freeing Gurobi model and environment - if self._ownsModel: + # Guard against partially-constructed objects (e.g. PyPy GC may call + # __del__ even if __init__ raised before setting these attributes). + if getattr(self, "_ownsModel", False): if self._model: GRBfreemodel(self._model) - if self._env and self._venv_loaded: + if self._env and getattr(self, "_venv_loaded", False): GRBfreeenv(self._env) def add_var( @@ -589,7 +590,9 @@ def set_max_iter(self, max_iter: int): def set_num_threads(self, threads: int): self.__threads = threads - def optimize(self, relax: bool = False, lp_preprocess: bool = False) -> OptimizationStatus: + def optimize( + self, relax: bool = False, lp_preprocess: bool = False + ) -> OptimizationStatus: # todo add branch_selector and incumbent_updater callbacks @ffi.callback( @@ -802,9 +805,7 @@ def callback( self.__obj_val = self.get_dbl_attr("ObjVal") self.__x = ffi.new("double[{}]".format(self.num_cols())) attr = "X".encode("utf-8") - st = GRBgetdblattrarray( - self._model, attr, 0, self.num_cols(), self.__x - ) + st = GRBgetdblattrarray(self._model, attr, 0, self.num_cols(), self.__x) if st: raise ParameterNotAvailable("Error querying Gurobi solution") # duals are only valid at optimality for Gurobi, skip Pi/RC diff --git a/mip/highs.py b/mip/highs.py index 32272554..2c965df1 100644 --- a/mip/highs.py +++ b/mip/highs.py @@ -14,6 +14,24 @@ # try loading the solver library ffi = cffi.FFI() + + +def _get_highsbox_libfile(): + """Return the path to the highsbox HiGHS shared library.""" + import highsbox + + root = highsbox.highs_dist_dir() + platform = sys.platform.lower() + if "linux" in platform: + return os.path.join(root, "lib", "libhighs.so") + elif platform.startswith("win"): + return os.path.join(root, "bin", "highs.dll") + elif any(platform.startswith(p) for p in ("darwin", "macos")): + return os.path.join(root, "lib", "libhighs.dylib") + else: + raise NotImplementedError(f"{sys.platform} not supported!") + + try: ENV_KEY = "PMIP_HIGHS_LIBRARY" if ENV_KEY in os.environ: @@ -22,26 +40,17 @@ logger.debug(f"Choosing HiGHS library {libfile} via {ENV_KEY}.") else: # Prefer highspy (official HiGHS package): its _core extension module - # statically links the full HiGHS C API and exports all symbols. + # contains the full HiGHS C API. On Linux/macOS all symbols are + # visible in the shared library; on Windows only the Python init + # symbol is exported from the .pyd, so the C API is not accessible + # via dlopen there. We detect that below and fall back to highsbox. try: import highspy._core as _highs_core libfile = _highs_core.__file__ logger.debug(f"Choosing HiGHS library {libfile} via highspy package.") except ImportError: - # Fall back to highsbox (third-party binary distribution). - import highsbox - - root = highsbox.highs_dist_dir() - platform = sys.platform.lower() - if "linux" in platform: - libfile = os.path.join(root, "lib", "libhighs.so") - elif platform.startswith("win"): - libfile = os.path.join(root, "bin", "highs.dll") - elif any(platform.startswith(p) for p in ("darwin", "macos")): - libfile = os.path.join(root, "lib", "libhighs.dylib") - else: - raise NotImplementedError(f"{sys.platform} not supported!") + libfile = _get_highsbox_libfile() logger.debug(f"Choosing HiGHS library {libfile} via highsbox package.") highslib = ffi.dlopen(libfile) @@ -678,11 +687,38 @@ """ ) + # On Windows, highspy's _core.pyd does not export C symbols (only the + # Python init function is exported from a .pyd). Detect this and + # automatically fall back to highsbox, which ships a proper highs.dll. + try: + _ = highslib.Highs_create + except AttributeError: + logger.warning( + f"HiGHS C API not accessible via {libfile!r} " + "(typical on Windows with highspy). " + "Falling back to highsbox." + ) + try: + libfile = _get_highsbox_libfile() + highslib = ffi.dlopen(libfile) + _ = highslib.Highs_create # verify symbols are accessible + logger.debug( + f"Choosing HiGHS library {libfile} via highsbox package (fallback)." + ) + except Exception as e: + logger.error( + f"highsbox fallback also failed: {e}. " + "HiGHS will not be available. " + "Install highsbox (pip install highsbox) for HiGHS support on Windows." + ) + has_highs = False + +if has_highs: STATUS_ERROR = highslib.kHighsStatusError # Initial capacities for the pending col/row caches (grows geometrically). -_CACHE_INITIAL_CAP = 8192 # column / row slots -_CACHE_INITIAL_NZ_CAP = 32768 # row-NZ slots (4 × col cap) +_CACHE_INITIAL_CAP = 8192 # column / row slots +_CACHE_INITIAL_NZ_CAP = 32768 # row-NZ slots (4 × col cap) _SIZEOF_INT = ffi.sizeof("int") _SIZEOF_DOUBLE = ffi.sizeof("double") @@ -734,28 +770,28 @@ def __init__(self, model: mip.Model, name: str, sense: str): # ── Column / row caches ──────────────────────────────────────────── # Pending columns (not yet committed to HiGHS). - self._col_committed = 0 # cols actually in HiGHS - self._col_fill = 0 # pending cols in cache + self._col_committed = 0 # cols actually in HiGHS + self._col_fill = 0 # pending cols in cache self._col_cap = _CACHE_INITIAL_CAP - self._c_col_lb = ffi.new("double[]", _CACHE_INITIAL_CAP) - self._c_col_ub = ffi.new("double[]", _CACHE_INITIAL_CAP) + self._c_col_lb = ffi.new("double[]", _CACHE_INITIAL_CAP) + self._c_col_ub = ffi.new("double[]", _CACHE_INITIAL_CAP) self._c_col_obj = ffi.new("double[]", _CACHE_INITIAL_CAP) - self._c_col_int = ffi.new("int[]", _CACHE_INITIAL_CAP) - self._col_names: list = [] # (logical_idx, encoded_bytes) pairs - self._pending_int_count = 0 # integer vars among pending cols + self._c_col_int = ffi.new("int[]", _CACHE_INITIAL_CAP) + self._col_names: list = [] # (logical_idx, encoded_bytes) pairs + self._pending_int_count = 0 # integer vars among pending cols # Pending rows (not yet committed to HiGHS). NZs stored in CSR. self._row_committed = 0 self._row_fill = 0 self._row_cap = _CACHE_INITIAL_CAP - self._c_row_lb = ffi.new("double[]", _CACHE_INITIAL_CAP) - self._c_row_ub = ffi.new("double[]", _CACHE_INITIAL_CAP) - self._c_row_starts = ffi.new("int[]", _CACHE_INITIAL_CAP) # NZ start per row + self._c_row_lb = ffi.new("double[]", _CACHE_INITIAL_CAP) + self._c_row_ub = ffi.new("double[]", _CACHE_INITIAL_CAP) + self._c_row_starts = ffi.new("int[]", _CACHE_INITIAL_CAP) # NZ start per row self._row_nz_fill = 0 self._row_nz_cap = _CACHE_INITIAL_NZ_CAP - self._c_row_indices = ffi.new("int[]", _CACHE_INITIAL_NZ_CAP) - self._c_row_values = ffi.new("double[]", _CACHE_INITIAL_NZ_CAP) - self._row_names: list = [] # (logical_idx, encoded_bytes) pairs + self._c_row_indices = ffi.new("int[]", _CACHE_INITIAL_NZ_CAP) + self._c_row_values = ffi.new("double[]", _CACHE_INITIAL_NZ_CAP) + self._row_names: list = [] # (logical_idx, encoded_bytes) pairs # Name→index dicts for O(1) lookups without flushing pending cols/rows. # Set to None after remove_vars/remove_constrs/read, which invalidate indices. @@ -764,72 +800,83 @@ def __init__(self, model: mip.Model, name: str, sense: str): def __del__(self): self._name_buffer = None - self._lib.Highs_destroy(self._model) + if getattr(self, "_model", None) is not None: + self._lib.Highs_destroy(self._model) # ── Cache grow / flush helpers ───────────────────────────────────────── def _grow_cols(self: "SolverHighs"): new_cap = self._col_cap * 2 n = self._col_fill - new_lb = ffi.new("double[]", new_cap) - new_ub = ffi.new("double[]", new_cap) + new_lb = ffi.new("double[]", new_cap) + new_ub = ffi.new("double[]", new_cap) new_obj = ffi.new("double[]", new_cap) - new_int = ffi.new("int[]", new_cap) - ffi.memmove(new_lb, self._c_col_lb, n * _SIZEOF_DOUBLE) - ffi.memmove(new_ub, self._c_col_ub, n * _SIZEOF_DOUBLE) + new_int = ffi.new("int[]", new_cap) + ffi.memmove(new_lb, self._c_col_lb, n * _SIZEOF_DOUBLE) + ffi.memmove(new_ub, self._c_col_ub, n * _SIZEOF_DOUBLE) ffi.memmove(new_obj, self._c_col_obj, n * _SIZEOF_DOUBLE) ffi.memmove(new_int, self._c_col_int, n * _SIZEOF_INT) - self._c_col_lb = new_lb - self._c_col_ub = new_ub + self._c_col_lb = new_lb + self._c_col_ub = new_ub self._c_col_obj = new_obj self._c_col_int = new_int - self._col_cap = new_cap + self._col_cap = new_cap def _grow_rows(self: "SolverHighs"): new_cap = self._row_cap * 2 n = self._row_fill - new_lb = ffi.new("double[]", new_cap) - new_ub = ffi.new("double[]", new_cap) - new_starts = ffi.new("int[]", new_cap) - ffi.memmove(new_lb, self._c_row_lb, n * _SIZEOF_DOUBLE) - ffi.memmove(new_ub, self._c_row_ub, n * _SIZEOF_DOUBLE) + new_lb = ffi.new("double[]", new_cap) + new_ub = ffi.new("double[]", new_cap) + new_starts = ffi.new("int[]", new_cap) + ffi.memmove(new_lb, self._c_row_lb, n * _SIZEOF_DOUBLE) + ffi.memmove(new_ub, self._c_row_ub, n * _SIZEOF_DOUBLE) ffi.memmove(new_starts, self._c_row_starts, n * _SIZEOF_INT) - self._c_row_lb = new_lb - self._c_row_ub = new_ub + self._c_row_lb = new_lb + self._c_row_ub = new_ub self._c_row_starts = new_starts - self._row_cap = new_cap + self._row_cap = new_cap def _grow_row_nz(self: "SolverHighs", needed: int): new_cap = max(self._row_nz_cap * 2, self._row_nz_fill + needed) nz = self._row_nz_fill - new_idx = ffi.new("int[]", new_cap) + new_idx = ffi.new("int[]", new_cap) new_val = ffi.new("double[]", new_cap) ffi.memmove(new_idx, self._c_row_indices, nz * _SIZEOF_INT) - ffi.memmove(new_val, self._c_row_values, nz * _SIZEOF_DOUBLE) + ffi.memmove(new_val, self._c_row_values, nz * _SIZEOF_DOUBLE) self._c_row_indices = new_idx - self._c_row_values = new_val - self._row_nz_cap = new_cap + self._c_row_values = new_val + self._row_nz_cap = new_cap def _flush_cols(self: "SolverHighs"): n = self._col_fill if n == 0: return - check(self._lib.Highs_addCols( - self._model, n, - self._c_col_obj, self._c_col_lb, self._c_col_ub, - 0, ffi.NULL, ffi.NULL, ffi.NULL, - )) - if self._pending_int_count > 0: - check(self._lib.Highs_changeColsIntegralityByRange( + check( + self._lib.Highs_addCols( self._model, - self._col_committed, - self._col_committed + n - 1, - self._c_col_int, - )) + n, + self._c_col_obj, + self._c_col_lb, + self._c_col_ub, + 0, + ffi.NULL, + ffi.NULL, + ffi.NULL, + ) + ) + if self._pending_int_count > 0: + check( + self._lib.Highs_changeColsIntegralityByRange( + self._model, + self._col_committed, + self._col_committed + n - 1, + self._c_col_int, + ) + ) for col_idx, name_bytes in self._col_names: check(self._lib.Highs_passColName(self._model, col_idx, name_bytes)) - self._col_committed += n - self._col_fill = 0 + self._col_committed += n + self._col_fill = 0 self._pending_int_count = 0 self._col_names.clear() @@ -837,17 +884,23 @@ def _flush_rows(self: "SolverHighs"): n = self._row_fill if n == 0: return - check(self._lib.Highs_addRows( - self._model, n, - self._c_row_lb, self._c_row_ub, - self._row_nz_fill, - self._c_row_starts, self._c_row_indices, self._c_row_values, - )) + check( + self._lib.Highs_addRows( + self._model, + n, + self._c_row_lb, + self._c_row_ub, + self._row_nz_fill, + self._c_row_starts, + self._c_row_indices, + self._c_row_values, + ) + ) for row_idx, name_bytes in self._row_names: self._lib.Highs_passRowName(self._model, row_idx, name_bytes) self._row_committed += n - self._row_fill = 0 - self._row_nz_fill = 0 + self._row_fill = 0 + self._row_nz_fill = 0 self._row_names.clear() def _flush(self: "SolverHighs"): @@ -970,13 +1023,13 @@ def add_var( # Normal path: buffer the column. if self._col_fill == self._col_cap: self._grow_cols() - self._c_col_lb[self._col_fill] = lb - self._c_col_ub[self._col_fill] = ub + self._c_col_lb[self._col_fill] = lb + self._c_col_ub[self._col_fill] = ub self._c_col_obj[self._col_fill] = obj self._c_col_int[self._col_fill] = self._var_type_map[var_type] self._col_fill += 1 if var_type != mip.CONTINUOUS: - self._num_int_vars += 1 + self._num_int_vars += 1 self._pending_int_count += 1 if name: self._col_names.append((col, name.encode("utf-8"))) @@ -1003,12 +1056,12 @@ def add_constr(self: "SolverHighs", lin_expr: "mip.LinExpr", name: str = ""): if self._row_nz_fill + num_nz > self._row_nz_cap: self._grow_row_nz(num_nz) - self._c_row_lb[self._row_fill] = lower - self._c_row_ub[self._row_fill] = upper + self._c_row_lb[self._row_fill] = lower + self._c_row_ub[self._row_fill] = upper self._c_row_starts[self._row_fill] = self._row_nz_fill for var, coef in lin_expr.expr.items(): self._c_row_indices[self._row_nz_fill] = var.idx - self._c_row_values[self._row_nz_fill] = coef + self._c_row_values[self._row_nz_fill] = coef self._row_nz_fill += 1 self._row_fill += 1 diff --git a/pyproject.toml b/pyproject.toml index 5edb731a..7821b925 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,14 +34,14 @@ classifiers = [ ] dynamic = ["version"] -dependencies = ["cffi>=1.15", "cbcbox>=2.929"] +dependencies = ["cffi>=1.15", "mipster>=0.2.4"] [project.optional-dependencies] numpy = [ "numpy>=1.25,<3", ] gurobi = ["gurobipy>=10"] -highs = ["highspy>=1.9.0"] +highs = ["highspy>=1.9.0", "highsbox>=1.9.0; sys_platform == 'win32'"] test = [ "pytest>=7.4,<9", "networkx>=2.8.8,<4",