diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 9601e0a9..0293ac94 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -43,6 +43,7 @@ Most users should keep calling ``model.solve(...)``. If you want more control, y **Performance** * ~10× faster direct solver communication (``io_api="direct"``), thanks to the new CSR-based matrix construction. Conversion helpers like ``to_highspy`` benefit too. +* Xpress now supports ``io_api="direct"``: the linopy model is loaded via the native ``loadproblem`` array API instead of being serialised through an LP/MPS file, with SOS constraints attached in-place. Adds ``model.to_xpress()`` matching the existing ``to_gurobipy`` / ``to_highspy`` / ``to_mosek`` helpers. * Writing the solution back to the model after solving is faster: it no longer rebuilds the constraint matrix, and now uses positional (rather than label-based) indexing — roughly 2× faster overall. **Deprecations** @@ -57,7 +58,7 @@ Most users should keep calling ``model.solve(...)``. If you want more control, y **Internal** -* Each ``Solver`` subclass now overrides at most three hooks: ``_build_direct`` (build the native model), ``_run_direct`` (run it), and ``_run_file`` (run the solver on an LP/MPS file). File-only solvers (CBC, GLPK, CPLEX, SCIP, Xpress, Knitro, COPT, MindOpt) only override ``_run_file``. +* Each ``Solver`` subclass now overrides at most three hooks: ``_build_direct`` (build the native model), ``_run_direct`` (run it), and ``_run_file`` (run the solver on an LP/MPS file). File-only solvers (CBC, GLPK, CPLEX, SCIP, Knitro, COPT, MindOpt) only override ``_run_file``. * New ``ConstraintLabelIndex`` cached on ``Model.constraints`` (mirrors the existing ``Variables.label_index``); ``ConstraintBase`` gains ``active_labels()`` and a ``range`` property; ``CSRConstraint`` exposes ``coords``. * ``linopy.common`` gains ``values_to_lookup_array``; the legacy pandas-based helpers ``series_to_lookup_array`` and ``lookup_vals`` are removed. ``model.to_gurobipy()`` / ``model.to_highspy()`` / ``to_cupdlpx(model)`` (and similar) all return the underlying solver model as before; internally they now go through ``Solver.from_model(model, io_api="direct")``. No user-visible change. diff --git a/linopy/io.py b/linopy/io.py index 36d7abb3..54adee87 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -685,6 +685,21 @@ def to_highspy( return solver.solver_model +def to_xpress( + m: Model, + explicit_coordinate_names: bool = False, + set_names: bool = True, +) -> Any: + """Build the xpress.problem instance for `m`.""" + solver = solvers.Xpress.from_model( + m, + io_api="direct", + explicit_coordinate_names=explicit_coordinate_names, + set_names=set_names, + ) + return solver.solver_model + + def to_cupdlpx(m: Model) -> cupdlpxModel: """Build the cupdlpx.Model for `m`.""" solver = solvers.cuPDLPx.from_model(m, io_api="direct") diff --git a/linopy/model.py b/linopy/model.py index 4eb91fc6..ef31d7d0 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -71,6 +71,7 @@ to_highspy, to_mosek, to_netcdf, + to_xpress, ) from linopy.matrices import MatrixAccessor from linopy.objective import Objective @@ -2116,4 +2117,6 @@ def reset_solution(self) -> None: to_cupdlpx = to_cupdlpx + to_xpress = to_xpress + to_block_files = to_block_files diff --git a/linopy/solvers.py b/linopy/solvers.py index 548db835..1b8aff78 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2013,10 +2013,12 @@ class Xpress(Solver[None]): { SolverFeature.INTEGER_VARIABLES, SolverFeature.QUADRATIC_OBJECTIVE, + SolverFeature.DIRECT_API, SolverFeature.LP_FILE_NAMES, SolverFeature.READ_MODEL_FROM_FILE, SolverFeature.SOLUTION_FILE_NOT_NEEDED, SolverFeature.IIS_COMPUTATION, + SolverFeature.SOS_CONSTRAINTS, } ) @@ -2025,12 +2027,191 @@ class Xpress(Solver[None]): def is_available(cls) -> bool: return _has_module("xpress") + def _build_direct( + self, + explicit_coordinate_names: bool = False, + set_names: bool = True, + **kwargs: Any, + ) -> None: + model = self.model + assert model is not None + problem = self._build_solver_model( + model, + explicit_coordinate_names=explicit_coordinate_names, + set_names=set_names, + ) + self.solver_model = problem + self.io_api = "direct" + self.sense = model.sense + self._cache_model_labels(model) + + @staticmethod + def _build_solver_model( + model: Model, + explicit_coordinate_names: bool = False, + set_names: bool = True, + ) -> xpress.problem: + """ + Build an ``xpress.problem`` that mirrors the linopy ``model`` via ``loadproblem``. + + ``loadproblem`` is Xpress' universal native-array entry point loading LP/QP/MIQP + in a single call; see the parameter reference at + https://www.fico.com/fico-xpress-optimization/docs/latest/solver/optimizer/python/HTML/problem.loadproblem.html. + SOS arguments are left ``None`` and sets are added afterwards via ``addSOS`` so + multi-dim ``add_sos_constraints`` can be grouped natively. + """ + model.constraints.sanitize_missings() + problem = xpress.problem() + + M = model.matrices + A = M.A + Q = M.Q + + if A is not None and A.nnz: + if A.format != "csc": + A = A.tocsc() + start = A.indptr.astype(np.int64, copy=False) + rowind = A.indices.astype(np.int64, copy=False) + rowcoef = A.data.astype(float, copy=False) + else: + start = np.zeros(len(M.vlabels) + 1, dtype=np.int64) + rowind = np.empty(0, dtype=np.int64) + rowcoef = np.empty(0, dtype=float) + + lb = np.asarray(M.lb, dtype=float) + ub = np.asarray(M.ub, dtype=float) + np.place(lb, np.isneginf(lb), -xpress.infinity) + np.place(ub, np.isposinf(ub), xpress.infinity) + + rowtype: np.ndarray + rhs: np.ndarray + if len(M.clabels): + sense = M.sense + rowtype = np.full(sense.shape, "E", dtype="U1") + rowtype[sense == "<"] = "L" + rowtype[sense == ">"] = "G" + rhs = np.asarray(M.b, dtype=float) + else: + rowtype = np.empty(0, dtype="U1") + rhs = np.empty(0, dtype=float) + + objqcol1: np.ndarray | None + objqcol2: np.ndarray | None + objqcoef: np.ndarray | None + if Q is not None and Q.nnz: + Qt = Q if Q.format == "coo" else triu(Q, format="coo") # codespell:ignore + mask = Qt.row <= Qt.col + objqcol1 = Qt.row[mask].astype(np.int64, copy=False) + objqcol2 = Qt.col[mask].astype(np.int64, copy=False) + objqcoef = Qt.data[mask].astype(float, copy=False) + else: + objqcol1 = None + objqcol2 = None + objqcoef = None + + vtypes = M.vtypes + integer_mask = (vtypes == "B") | (vtypes == "I") + if integer_mask.any(): + entind = np.flatnonzero(integer_mask).astype(np.int64, copy=False) + coltype = vtypes[entind] + else: + entind = None + coltype = None + + problem.loadproblem( + probname="linopy", + rowtype=rowtype, + rhs=rhs, + rng=None, + objcoef=np.asarray(M.c, dtype=float), + start=start, + collen=None, + rowind=rowind, + rowcoef=rowcoef, + lb=lb, + ub=ub, + objqcol1=objqcol1, + objqcol2=objqcol2, + objqcoef=objqcoef, + qrowind=None, + nrowqcoefs=None, + rowqcol1=None, + rowqcol2=None, + rowqcoef=None, + coltype=coltype, + entind=entind, + limit=None, + settype=None, + setstart=None, + setind=None, + refval=None, + ) + + if model.objective.sense == "max": + problem.chgobjsense(xpress.maximize) + + if set_names: + print_variable, print_constraint = linopy.io.get_printers_scalar( + model, explicit_coordinate_names=explicit_coordinate_names + ) + vnames = print_variable(M.vlabels) + if vnames: + problem.addnames(xpress_Namespaces.COLUMN, vnames, 0, len(vnames) - 1) + cnames = print_constraint(M.clabels) + if cnames: + problem.addnames(xpress_Namespaces.ROW, cnames, 0, len(cnames) - 1) + + if model.variables.sos: + for var_name in model.variables.sos: + var = model.variables.sos[var_name] + sos_type: int = var.attrs[SOS_TYPE_ATTR] # type: ignore[assignment] + sos_dim: str = var.attrs[SOS_DIM_ATTR] # type: ignore[assignment] + + def add_sos(s: xr.DataArray, sos_type: int, sos_dim: str) -> None: + s = s.squeeze() + labels = s.values.flatten() + mask = labels != -1 + if not mask.any(): + return + indices = labels[mask].tolist() + weights = s.coords[sos_dim].values[mask].tolist() + problem.addSOS(indices, weights, type=sos_type) + + others = [dim for dim in var.labels.dims if dim != sos_dim] + if not others: + add_sos(var.labels, sos_type, sos_dim) + else: + stacked = var.labels.stack(_sos_group=others) + for _, s in stacked.groupby("_sos_group"): + add_sos(s.unstack("_sos_group"), sos_type, sos_dim) + + return problem + @classmethod def runtime_features(cls) -> frozenset[SolverFeature]: if _installed_version_in("xpress", ">=9.8.0"): return frozenset({SolverFeature.GPU_ACCELERATION}) return frozenset() + def _run_direct( + self, + solution_fn: Path | None = None, + log_fn: Path | None = None, + warmstart_fn: Path | None = None, + basis_fn: Path | None = None, + env: None = None, + **kw: Any, + ) -> Result: + return self._solve( + self.solver_model, + solution_fn=solution_fn, + log_fn=log_fn, + warmstart_fn=warmstart_fn, + basis_fn=basis_fn, + io_api=self.io_api, + sense=self.sense, + ) + def _run_file( self, solution_fn: Path | None = None, @@ -2042,6 +2223,34 @@ def _run_file( ) -> Result: problem_fn = self._problem_fn assert problem_fn is not None + io_api = read_io_api_from_problem_file(problem_fn) + sense = read_sense_from_problem_file(problem_fn) + + m = xpress.problem() + m.read(path_to_string(problem_fn)) + + return self._solve( + m, + solution_fn=solution_fn, + log_fn=log_fn, + warmstart_fn=warmstart_fn, + basis_fn=basis_fn, + io_api=io_api, + sense=sense, + from_file=True, + ) + + def _solve( + self, + m: xpress.problem, + solution_fn: Path | None, + log_fn: Path | None, + warmstart_fn: Path | None, + basis_fn: Path | None, + io_api: str | None, + sense: str | None, + from_file: bool = False, + ) -> Result: CONDITION_MAP = { xpress.SolStatus.NOTFOUND: "unknown", xpress.SolStatus.OPTIMAL: "optimal", @@ -2050,57 +2259,30 @@ def _run_file( xpress.SolStatus.UNBOUNDED: "unbounded", } - io_api = read_io_api_from_problem_file(problem_fn) - sense = read_sense_from_problem_file(problem_fn) - - m = xpress.problem() - - try: # Try new API first - m.readProb(path_to_string(problem_fn)) - except AttributeError: # Fallback to old API - m.read(path_to_string(problem_fn)) - - # Set solver options - new API uses setControl per option, old API accepts dict if self.solver_options is not None: m.setControl(self.solver_options) if log_fn is not None: - try: # Try new API first - m.setLogFile(path_to_string(log_fn)) - except AttributeError: # Fallback to old API - m.setlogfile(path_to_string(log_fn)) + m.setlogfile(path_to_string(log_fn)) if warmstart_fn is not None: - try: # Try new API first - m.readBasis(path_to_string(warmstart_fn)) - except AttributeError: # Fallback to old API - m.readbasis(path_to_string(warmstart_fn)) + m.readbasis(path_to_string(warmstart_fn)) m.optimize() - # if the solver is stopped (timelimit for example), postsolve the problem if m.attributes.solvestatus == xpress.enums.SolveStatus.STOPPED: - try: # Try new API first - m.postSolve() - except AttributeError: # Fallback to old API - m.postsolve() + m.postsolve() if basis_fn is not None: try: - try: # Try new API first - m.writeBasis(path_to_string(basis_fn)) - except AttributeError: # Fallback to old API - m.writebasis(path_to_string(basis_fn)) - except (xpress.SolverError, xpress.ModelError) as err: + m.writebasis(path_to_string(basis_fn)) + except (xpress.SolverError, xpress.ModelError) as err: # pragma: no cover logger.info("No model basis stored. Raised error: %s", err) if solution_fn is not None: try: - try: # Try new API first - m.writeBinSol(path_to_string(solution_fn)) - except AttributeError: # Fallback to old API - m.writebinsol(path_to_string(solution_fn)) - except (xpress.SolverError, xpress.ModelError) as err: + m.writebinsol(path_to_string(solution_fn)) + except (xpress.SolverError, xpress.ModelError) as err: # pragma: no cover logger.info("Unable to save solution file. Raised error: %s", err) condition = m.attributes.solstatus @@ -2111,26 +2293,36 @@ def _run_file( def get_solver_solution() -> Solution: objective = m.attributes.objval - sol = _solution_from_names( - np.asarray(m.getSolution(), dtype=float), - [v.name for v in m.getVariable()], - self._n_vars, - ) + sol_values = np.asarray(m.getSolution(), dtype=float) + if from_file: + sol = _solution_from_names( + sol_values, + [v.name for v in m.getVariable()], + self._n_vars, + ) + else: + sol = _solution_from_labels(sol_values, self._vlabels, self._n_vars) try: if m.attributes.rows == 0: dual = np.array([], dtype=float) else: - try: # Try new API first - _dual = m.getDuals() - except AttributeError: # Fallback to old API - _dual = m.getDual() - dual = _solution_from_names( - np.asarray(_dual, dtype=float), - [c.name for c in m.getConstraint()], - self._n_cons, - ) - except (xpress.SolverError, xpress.ModelError, SystemError): + dual_values = np.asarray(m.getDual(), dtype=float) + if from_file: + dual = _solution_from_names( + dual_values, + [c.name for c in m.getConstraint()], + self._n_cons, + ) + else: + dual = _solution_from_labels( + dual_values, self._clabels, self._n_cons + ) + except ( + xpress.SolverError, + xpress.ModelError, + SystemError, + ): # pragma: no cover logger.warning("Dual values of MILP couldn't be parsed") dual = np.array([], dtype=float) diff --git a/test/test_io.py b/test/test_io.py index b049c0dc..fba65aab 100644 --- a/test/test_io.py +++ b/test/test_io.py @@ -307,6 +307,22 @@ def test_to_mosek(model: Model) -> None: assert task.getnumvar() > 0 +@pytest.mark.skipif("xpress" not in available_solvers, reason="Xpress not installed") +def test_to_xpress(model: Model) -> None: + p = model.to_xpress() + assert p.attributes.cols > 0 + assert p.attributes.rows > 0 + + +@pytest.mark.skipif("xpress" not in available_solvers, reason="Xpress not installed") +def test_to_xpress_no_names(model: Model) -> None: + p_with = model.to_xpress(set_names=True) + p_without = model.to_xpress(set_names=False) + names_with = [v.name for v in p_with.getVariable()] + names_without = [v.name for v in p_without.getVariable()] + assert names_with != names_without + + @pytest.mark.skipif("cupdlpx" not in available_solvers, reason="cuPDLPx not installed") def test_to_cupdlpx(model: Model) -> None: cu = model.to_cupdlpx() diff --git a/test/test_sos_constraints.py b/test/test_sos_constraints.py index 5d94162e..f340f768 100644 --- a/test/test_sos_constraints.py +++ b/test/test_sos_constraints.py @@ -5,6 +5,7 @@ import numpy as np import pandas as pd import pytest +import xarray as xr from linopy import Model, available_solvers @@ -137,6 +138,64 @@ def test_sos2_binary_maximize_different_coeffs() -> None: assert np.isclose(m.objective.value, 4) +@pytest.mark.skipif("xpress" not in available_solvers, reason="Xpress not installed") +def test_to_xpress_emits_sos_constraints() -> None: + m = Model() + segments = pd.Index([0.0, 0.5, 1.0], name="seg") + var = m.add_variables(coords=[segments], name="lambda") + m.add_sos_constraints(var, sos_type=1, sos_dim="seg") + m.add_objective(var.sum()) + + problem = m.to_xpress() + assert problem.attributes.sets == 1 + + +@pytest.mark.skipif("xpress" not in available_solvers, reason="Xpress not installed") +def test_to_xpress_emits_grouped_sos_constraints() -> None: + m = Model() + groups = pd.Index(["a", "b"], name="group") + segments = pd.Index([0.0, 0.5, 1.0], name="seg") + var = m.add_variables(coords=[groups, segments], name="lambda") + m.add_sos_constraints(var, sos_type=1, sos_dim="seg") + m.add_objective(var.sum()) + + problem = m.to_xpress() + assert problem.attributes.sets == len(groups) + + +@pytest.mark.skipif("xpress" not in available_solvers, reason="Xpress not installed") +def test_sos2_xpress_direct() -> None: + m = Model() + locations = pd.Index([0, 1, 2], name="locations") + build = m.add_variables(coords=[locations], name="build", binary=True) + m.add_sos_constraints(build, sos_type=2, sos_dim="locations") + m.add_objective(build * np.array([1, 2, 3]), sense="max") + + m.solve(solver_name="xpress", io_api="direct") + + assert np.isclose(build.solution.values, [0, 1, 1]).all() + assert m.objective.value is not None + assert np.isclose(m.objective.value, 5) + + +@pytest.mark.skipif("xpress" not in available_solvers, reason="Xpress not installed") +def test_qp_sos1_xpress_direct() -> None: + m = Model() + seg = pd.Index([0, 1, 2], name="seg") + x = m.add_variables(lower=0, upper=10, coords=[seg], name="x") + m.add_sos_constraints(x, sos_type=1, sos_dim="seg") + m.add_constraints(x.sum() >= 5) + + linear_coeffs = xr.DataArray([0.0, -10.0, 0.0], coords=[seg]) + m.add_objective((x * x).sum() + (linear_coeffs * x).sum(), sense="min") + + m.solve(solver_name="xpress", io_api="direct") + + assert np.isclose(x.solution.values, [0, 5, 0]).all() + assert m.objective.value is not None + assert np.isclose(m.objective.value, -25) + + def test_unsupported_solver_raises_error() -> None: m = Model() locations = pd.Index([0, 1, 2], name="locations")