From 8bf7b4bd7507c251d8163ac8cf7623732e9089db Mon Sep 17 00:00:00 2001 From: Fabian Date: Mon, 18 May 2026 10:30:41 +0200 Subject: [PATCH 01/11] feat(xpress): add direct API support via loadproblem Adds io_api='direct' for Xpress: loads the model through the native loadproblem array API instead of writing an LP/MPS file. Includes a model.to_xpress() helper mirroring to_gurobipy/to_highspy/to_mosek, SOS attachment, and feature flags (DIRECT_API, SOS_CONSTRAINTS). Refactors _run_file to share a _solve helper with the new _run_direct. --- doc/release_notes.rst | 3 +- linopy/io.py | 15 +++ linopy/model.py | 3 + linopy/solvers.py | 277 ++++++++++++++++++++++++++++++++++++------ test/test_io.py | 16 +++ 5 files changed, 279 insertions(+), 35 deletions(-) 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 1e4ae637..38b86c76 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 @@ -2113,4 +2114,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..8370ed50 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,190 @@ 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``.""" + 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) + + 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 coo + 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="", + 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": + try: + problem.chgObjSense(xpress.ObjSense.MAXIMIZE) + except AttributeError: + 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: + try: + problem.addNames(xpress_Namespaces.COLUMN, vnames, 0, len(vnames) - 1) + except AttributeError: + problem.addnames(xpress_Namespaces.COLUMN, vnames, 0, len(vnames) - 1) + cnames = print_constraint(M.clabels) + if cnames: + try: + problem.addNames(xpress_Namespaces.ROW, cnames, 0, len(cnames) - 1) + except AttributeError: + 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 +2222,37 @@ 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() + try: + m.readProb(path_to_string(problem_fn)) + except AttributeError: + 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,55 +2261,43 @@ 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 + try: m.setLogFile(path_to_string(log_fn)) - except AttributeError: # Fallback to old API + except AttributeError: m.setlogfile(path_to_string(log_fn)) if warmstart_fn is not None: - try: # Try new API first + try: m.readBasis(path_to_string(warmstart_fn)) - except AttributeError: # Fallback to old API + except AttributeError: 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 + try: m.postSolve() - except AttributeError: # Fallback to old API + except AttributeError: m.postsolve() if basis_fn is not None: try: - try: # Try new API first + try: m.writeBasis(path_to_string(basis_fn)) - except AttributeError: # Fallback to old API + except AttributeError: m.writebasis(path_to_string(basis_fn)) except (xpress.SolverError, xpress.ModelError) as err: logger.info("No model basis stored. Raised error: %s", err) if solution_fn is not None: try: - try: # Try new API first + try: m.writeBinSol(path_to_string(solution_fn)) - except AttributeError: # Fallback to old API + except AttributeError: m.writebinsol(path_to_string(solution_fn)) except (xpress.SolverError, xpress.ModelError) as err: logger.info("Unable to save solution file. Raised error: %s", err) @@ -2111,25 +2310,35 @@ 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 + try: _dual = m.getDuals() - except AttributeError: # Fallback to old API + except AttributeError: _dual = m.getDual() - dual = _solution_from_names( - np.asarray(_dual, dtype=float), - [c.name for c in m.getConstraint()], - self._n_cons, - ) + dual_values = np.asarray(_dual, 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): 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() From c1e792b1a3df6b52bdcc7892d1aebc26e6d601ca Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 08:31:10 +0000 Subject: [PATCH 02/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- linopy/solvers.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index 8370ed50..e909ef4f 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2089,7 +2089,9 @@ def _build_solver_model( 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 coo + Qt = ( + Q if Q.format == "coo" else triu(Q, format="coo") + ) # codespell:ignore coo mask = Qt.row <= Qt.col objqcol1 = Qt.row[mask].astype(np.int64, copy=False) objqcol2 = Qt.col[mask].astype(np.int64, copy=False) @@ -2150,9 +2152,13 @@ def _build_solver_model( vnames = print_variable(M.vlabels) if vnames: try: - problem.addNames(xpress_Namespaces.COLUMN, vnames, 0, len(vnames) - 1) + problem.addNames( + xpress_Namespaces.COLUMN, vnames, 0, len(vnames) - 1 + ) except AttributeError: - problem.addnames(xpress_Namespaces.COLUMN, vnames, 0, len(vnames) - 1) + problem.addnames( + xpress_Namespaces.COLUMN, vnames, 0, len(vnames) - 1 + ) cnames = print_constraint(M.clabels) if cnames: try: From 8e0c2942cd49f7f6a4e78a2fdd84b41b01e73a5f Mon Sep 17 00:00:00 2001 From: Fabian Date: Mon, 18 May 2026 10:34:56 +0200 Subject: [PATCH 03/11] fix: keep codespell:ignore inline with coo occurrences --- linopy/solvers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index e909ef4f..cb091945 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2089,9 +2089,7 @@ def _build_solver_model( 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 coo + 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) From 761a99b6c858e8b4b8e92b5724c6301e7ae003b3 Mon Sep 17 00:00:00 2001 From: Fabian Date: Mon, 18 May 2026 10:40:29 +0200 Subject: [PATCH 04/11] fix(mypy): annotate Xpress rowtype/rhs to unify branches --- linopy/solvers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/linopy/solvers.py b/linopy/solvers.py index cb091945..85ad723d 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2075,6 +2075,8 @@ def _build_solver_model( 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") From 3f35a137aece93de36ba5ccbd64e4c509f27253e Mon Sep 17 00:00:00 2001 From: Fabian Date: Mon, 18 May 2026 11:22:28 +0200 Subject: [PATCH 05/11] test(xpress): cover SOS via direct API (to_xpress + solve) --- test/test_sos_constraints.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/test_sos_constraints.py b/test/test_sos_constraints.py index 5d94162e..f1762f37 100644 --- a/test/test_sos_constraints.py +++ b/test/test_sos_constraints.py @@ -137,6 +137,33 @@ 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_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) + + def test_unsupported_solver_raises_error() -> None: m = Model() locations = pd.Index([0, 1, 2], name="locations") From c5c7f20970efd0aa94897eef291b95914a06bedb Mon Sep 17 00:00:00 2001 From: Fabian Date: Mon, 18 May 2026 11:49:52 +0200 Subject: [PATCH 06/11] test(xpress): cover multi-dimensional SOS grouping --- test/test_sos_constraints.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/test_sos_constraints.py b/test/test_sos_constraints.py index f1762f37..b17f9267 100644 --- a/test/test_sos_constraints.py +++ b/test/test_sos_constraints.py @@ -149,6 +149,19 @@ def test_to_xpress_emits_sos_constraints() -> None: 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() From db524bb2418e3ff18e36814d45669719e51ec324 Mon Sep 17 00:00:00 2001 From: Fabian Date: Mon, 18 May 2026 13:13:25 +0200 Subject: [PATCH 07/11] refactor(xpress): collapse new/old API dispatches to lowercase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lowercase Xpress API (addnames, chgobjsense, read, setlogfile, readbasis, postsolve, writebasis, writebinsol, getDual) exists in every released xpress version. Drop the try/except dispatch to the PascalCase aliases — they only added uncovered fallback branches without behavioural benefit on either 9.4 or 9.8+. --- linopy/solvers.py | 55 +++++++++-------------------------------------- 1 file changed, 10 insertions(+), 45 deletions(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index 85ad723d..8cbb5aa8 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2140,10 +2140,7 @@ def _build_solver_model( ) if model.objective.sense == "max": - try: - problem.chgObjSense(xpress.ObjSense.MAXIMIZE) - except AttributeError: - problem.chgobjsense(xpress.maximize) + problem.chgobjsense(xpress.maximize) if set_names: print_variable, print_constraint = linopy.io.get_printers_scalar( @@ -2151,20 +2148,10 @@ def _build_solver_model( ) vnames = print_variable(M.vlabels) if vnames: - try: - problem.addNames( - xpress_Namespaces.COLUMN, vnames, 0, len(vnames) - 1 - ) - except AttributeError: - problem.addnames( - xpress_Namespaces.COLUMN, vnames, 0, len(vnames) - 1 - ) + problem.addnames(xpress_Namespaces.COLUMN, vnames, 0, len(vnames) - 1) cnames = print_constraint(M.clabels) if cnames: - try: - problem.addNames(xpress_Namespaces.ROW, cnames, 0, len(cnames) - 1) - except AttributeError: - problem.addnames(xpress_Namespaces.ROW, cnames, 0, len(cnames) - 1) + problem.addnames(xpress_Namespaces.ROW, cnames, 0, len(cnames) - 1) if model.variables.sos: for var_name in model.variables.sos: @@ -2232,10 +2219,7 @@ def _run_file( sense = read_sense_from_problem_file(problem_fn) m = xpress.problem() - try: - m.readProb(path_to_string(problem_fn)) - except AttributeError: - m.read(path_to_string(problem_fn)) + m.read(path_to_string(problem_fn)) return self._solve( m, @@ -2271,40 +2255,25 @@ def _solve( m.setControl(self.solver_options) if log_fn is not None: - try: - m.setLogFile(path_to_string(log_fn)) - except AttributeError: - m.setlogfile(path_to_string(log_fn)) + m.setlogfile(path_to_string(log_fn)) if warmstart_fn is not None: - try: - m.readBasis(path_to_string(warmstart_fn)) - except AttributeError: - m.readbasis(path_to_string(warmstart_fn)) + m.readbasis(path_to_string(warmstart_fn)) m.optimize() if m.attributes.solvestatus == xpress.enums.SolveStatus.STOPPED: - try: - m.postSolve() - except AttributeError: - m.postsolve() + m.postsolve() if basis_fn is not None: try: - try: - m.writeBasis(path_to_string(basis_fn)) - except AttributeError: - m.writebasis(path_to_string(basis_fn)) + m.writebasis(path_to_string(basis_fn)) except (xpress.SolverError, xpress.ModelError) as err: logger.info("No model basis stored. Raised error: %s", err) if solution_fn is not None: try: - try: - m.writeBinSol(path_to_string(solution_fn)) - except AttributeError: - m.writebinsol(path_to_string(solution_fn)) + m.writebinsol(path_to_string(solution_fn)) except (xpress.SolverError, xpress.ModelError) as err: logger.info("Unable to save solution file. Raised error: %s", err) @@ -2330,11 +2299,7 @@ def get_solver_solution() -> Solution: if m.attributes.rows == 0: dual = np.array([], dtype=float) else: - try: - _dual = m.getDuals() - except AttributeError: - _dual = m.getDual() - dual_values = np.asarray(_dual, dtype=float) + dual_values = np.asarray(m.getDual(), dtype=float) if from_file: dual = _solution_from_names( dual_values, From cc4799e62129e8d29d13f970729505f7913bd5f4 Mon Sep 17 00:00:00 2001 From: Fabian Date: Mon, 18 May 2026 13:38:01 +0200 Subject: [PATCH 08/11] test(xpress): mark defensive exception handlers as no-cover --- linopy/solvers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index 8cbb5aa8..ebe5eb27 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2268,13 +2268,13 @@ def _solve( if basis_fn is not None: try: m.writebasis(path_to_string(basis_fn)) - except (xpress.SolverError, xpress.ModelError) as err: + 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: m.writebinsol(path_to_string(solution_fn)) - except (xpress.SolverError, xpress.ModelError) as err: + 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 @@ -2310,7 +2310,7 @@ def get_solver_solution() -> Solution: dual = _solution_from_labels( dual_values, self._clabels, self._n_cons ) - except (xpress.SolverError, xpress.ModelError, SystemError): + except (xpress.SolverError, xpress.ModelError, SystemError): # pragma: no cover logger.warning("Dual values of MILP couldn't be parsed") dual = np.array([], dtype=float) From e302e4fcea1dcd0bc1b068f0a0fb9ace8bff92eb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 11:38:10 +0000 Subject: [PATCH 09/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- linopy/solvers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index ebe5eb27..cbff8a53 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2310,7 +2310,11 @@ def get_solver_solution() -> Solution: dual = _solution_from_labels( dual_values, self._clabels, self._n_cons ) - except (xpress.SolverError, xpress.ModelError, SystemError): # pragma: no cover + except ( + xpress.SolverError, + xpress.ModelError, + SystemError, + ): # pragma: no cover logger.warning("Dual values of MILP couldn't be parsed") dual = np.array([], dtype=float) From d005e044778f40721e98eea6684bef517a2c0a66 Mon Sep 17 00:00:00 2001 From: Fabian Hofmann Date: Mon, 18 May 2026 14:38:22 +0200 Subject: [PATCH 10/11] Apply suggestions from code review Co-authored-by: Felix <117816358+FBumann@users.noreply.github.com> --- linopy/solvers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index cbff8a53..d6298a45 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2111,7 +2111,7 @@ def _build_solver_model( coltype = None problem.loadproblem( - probname="", + probname="linopy", rowtype=rowtype, rhs=rhs, rng=None, From e10937d5910c2bfeeb7e655cb647b8c4a570d8e1 Mon Sep 17 00:00:00 2001 From: Fabian Date: Mon, 18 May 2026 14:50:13 +0200 Subject: [PATCH 11/11] docs(xpress): document loadproblem; test(xpress): QP+SOS via direct API --- linopy/solvers.py | 10 +++++++++- test/test_sos_constraints.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index d6298a45..1b8aff78 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2051,7 +2051,15 @@ def _build_solver_model( explicit_coordinate_names: bool = False, set_names: bool = True, ) -> xpress.problem: - """Build an ``xpress.problem`` that mirrors the linopy ``model`` via ``loadproblem``.""" + """ + 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() diff --git a/test/test_sos_constraints.py b/test/test_sos_constraints.py index b17f9267..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 @@ -177,6 +178,24 @@ def test_sos2_xpress_direct() -> 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")