From e41b4c58d5848e0cacfc894a0f431f220699bbb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Kolouch=20Grabovsk=C3=BD?= Date: Wed, 29 Apr 2026 16:19:26 +0200 Subject: [PATCH 01/10] fix: pick solution with better status in Mosek interface Mosek can produce two distinct solutions with separate statuses: an interior-point solution (soltype.itr) and a basic solution (soltype.bas) obtained by basis identification (crossover). Previously, linopy's Mosek interface only read the IPM solution. If the IPM terminated with solsta.dual_infeas_cer but crossover then recovers an optimal basic solution, linopy would report infeasible_or_unbounded with Objective: nan and the actual optimal solution would be silently discarded, even though Mosek itself had it. After this change, linopy picks the solution (between bas, itr and itg) with the best score, with the order being solsta.[integer_]optimal > any other defined status > undefined. If both bas and itr are optimal, the itr solution is chosen to preserve backward compatibility with the previous behavior. A similar bug was present in cvxpy (see cvxpy/cvxpy#335), fixed by @aszekMosek in cvxpy/cvxpy#347. Patch co-authored with Claude Opus 4.7. Fixes #665 --- doc/release_notes.rst | 1 + linopy/solvers.py | 80 ++++++++++++++++++++++----- test/test_solvers.py | 126 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 191 insertions(+), 16 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index e5b7033f..d3b12562 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -112,6 +112,7 @@ Version 0.7.0 * ``add_variables`` no longer ignores ``coords`` when ``lower`` / ``upper`` are DataArrays, and handles MultiIndex coords correctly with scalar bounds. * ``Model.to_netcdf`` no longer fails on the scipy netCDF backend when variables or constraints have MultiIndex coords; level names are now serialised as a JSON string (the legacy list form remains readable). * CPLEX no longer errors on quality attributes that aren't always available. +* Fix Mosek interface to inspect both the basic and IPM solutions and pick the one with the better status, so that an optimal crossover solution is not discarded when IPM terminates with a (near-)Farkas certificate. **Breaking Changes** diff --git a/linopy/solvers.py b/linopy/solvers.py index f9281c3d..4d307c2d 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2694,6 +2694,58 @@ def get_solver_solution() -> Solution: mosek_bas_re = re.compile(r" (XL|XU)\s+([^ \t]+)\s+([^ \t]+)| (LL|UL|BS)\s+([^ \t]+)") +def _choose_mosek_solution(task: mosek.Task) -> mosek.soltype | None: + """ + Pick the Mosek solution with the best status available. + + Mosek may return up to three solutions per task: interior-point + (``soltype.itr``), basic (``soltype.bas``), and integer + (``soltype.itg``). Each carries its own ``solsta``: on a numerically + marginal LP solved with the default IPM+crossover, the interior-point + solver may terminate with ``solsta.dual_infeas_cer`` while crossover + recovers ``solsta.optimal`` for the basic solution. Reading only the + interior-point solution would discard the actual optimum. + + Ranking, best to worst: ``solsta.optimal`` / ``solsta.integer_optimal`` + > any other defined status > undefined. On a tie between ``bas`` and + ``itr`` (e.g. both ``optimal``) we prefer ``itr`` to preserve historical + behaviour. If ``itg`` is defined it always wins, since integer and + continuous solutions do not coexist for a well-posed task. + + Returns ``None`` if no solution is defined at all (e.g. the optimizer + crashed before producing one). + """ + + def _is_defined(soltype: mosek.soltype) -> bool: + try: + return bool(task.solutiondef(soltype)) + except mosek.Error: + return False + + if _is_defined(mosek.soltype.itg): + return mosek.soltype.itg + + optimal_statuses = {mosek.solsta.optimal, mosek.solsta.integer_optimal} + + best: mosek.soltype | None = None + best_score = -1 + # Iterate bas first and only then itr so that on a score tie + # itr wins, preserving the historical default for the common LP case. + for candidate in [mosek.soltype.bas, mosek.soltype.itr]: + if not _is_defined(candidate): + continue + try: + solsta = task.getsolsta(candidate) + except mosek.Error: + continue + score = 1 if solsta in optimal_statuses else 0 + if score >= best_score: + best = candidate + best_score = score + + return best + + class Mosek(Solver[None]): """ Solver subclass for the Mosek solver. @@ -3050,25 +3102,25 @@ def _solve( f.write(f" UL {namex}\n") f.write("ENDATA\n") - soltype = None - possible_soltypes = [ - mosek.soltype.bas, - mosek.soltype.itr, - mosek.soltype.itg, - ] - for possible_soltype in possible_soltypes: - try: - if m.solutiondef(possible_soltype): - soltype = possible_soltype - except mosek.Error: - pass + # Inspect both bas and itr (and itg for MILPs) and pick the + # solution with the best status. Reading only the interior-point + # solution may discard a valid crossover optimum. + soltype = _choose_mosek_solution(m) - if solution_fn is not None: + if solution_fn is not None and soltype is not None: try: - m.writesolution(mosek.soltype.bas, path_to_string(solution_fn)) + m.writesolution(soltype, path_to_string(solution_fn)) except mosek.Error as err: logger.info("Unable to save solution file. Raised error: %s", err) + if soltype is None: + condition = "no solution available" + status = Status.from_termination_condition( + TerminationCondition.internal_solver_error + ) + status.legacy_status = condition + return Result(status, Solution()) + condition = str(m.getsolsta(soltype)) termination_condition = CONDITION_MAP.get(condition, condition) status = Status.from_termination_condition(termination_condition) diff --git a/test/test_solvers.py b/test/test_solvers.py index 1109c4c0..f031aff4 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -6,6 +6,7 @@ """ from pathlib import Path +from unittest.mock import MagicMock import numpy as np import pytest @@ -368,7 +369,9 @@ def test_knitro_solver_no_log(tmp_path: Path) -> None: @pytest.mark.skipif( "gurobi" not in set(solvers.licensed_solvers), reason="Gurobi is not installed" ) -def test_gurobi_environment_with_dict(model: Model, tmp_path: Path) -> None: # noqa: F811 +def test_gurobi_environment_with_dict( + model: Model, tmp_path: Path +) -> None: # noqa: F811 gurobi = solvers.Gurobi() mps_file = tmp_path / "problem.mps" @@ -394,7 +397,9 @@ def test_gurobi_environment_with_dict(model: Model, tmp_path: Path) -> None: # @pytest.mark.skipif( "gurobi" not in set(solvers.licensed_solvers), reason="Gurobi is not installed" ) -def test_gurobi_environment_with_gurobi_env(model: Model, tmp_path: Path) -> None: # noqa: F811 +def test_gurobi_environment_with_gurobi_env( + model: Model, tmp_path: Path +) -> None: # noqa: F811 import gurobipy as gp gurobi = solvers.Gurobi() @@ -567,3 +572,120 @@ def test_assign_result_without_solver_kwarg_leaves_solver_unset(self) -> None: m.assign_result(result) # no solver kwarg assert m.solver is None + +def _make_mosek_task_mock( + *, bas_solsta=None, itr_solsta=None, itg_solsta=None +) -> MagicMock: + """Build a ``mosek.Task`` mock with controlled per-soltype statuses.""" + mosek = pytest.importorskip("mosek", reason="Mosek is not installed") + + defined = { + mosek.soltype.bas: bas_solsta, + mosek.soltype.itr: itr_solsta, + mosek.soltype.itg: itg_solsta, + } + + task = MagicMock() + task.solutiondef.side_effect = lambda st: defined[st] is not None + task.getsolsta.side_effect = lambda st: defined[st] + return task + + +def test_choose_mosek_solution_prefers_basic_when_itr_is_farkas() -> None: + """When the IPM ends in a Farkas certificate but crossover is optimal, pick bas.""" + mosek = pytest.importorskip("mosek", reason="Mosek is not installed") + task = _make_mosek_task_mock( + bas_solsta=mosek.solsta.optimal, + itr_solsta=mosek.solsta.dual_infeas_cer, + ) + assert solvers._choose_mosek_solution(task) is mosek.soltype.bas + + +def test_choose_mosek_solution_prefers_itr_on_tie() -> None: + """Both bas and itr optimal: prefer itr to preserve historical default.""" + mosek = pytest.importorskip("mosek", reason="Mosek is not installed") + task = _make_mosek_task_mock( + bas_solsta=mosek.solsta.optimal, + itr_solsta=mosek.solsta.optimal, + ) + assert solvers._choose_mosek_solution(task) is mosek.soltype.itr + + +def test_choose_mosek_solution_only_itr_defined() -> None: + mosek = pytest.importorskip("mosek", reason="Mosek is not installed") + task = _make_mosek_task_mock(itr_solsta=mosek.solsta.optimal) + assert solvers._choose_mosek_solution(task) is mosek.soltype.itr + + +def test_choose_mosek_solution_only_bas_defined() -> None: + mosek = pytest.importorskip("mosek", reason="Mosek is not installed") + task = _make_mosek_task_mock(bas_solsta=mosek.solsta.optimal) + assert solvers._choose_mosek_solution(task) is mosek.soltype.bas + + +def test_choose_mosek_solution_returns_none_when_nothing_defined() -> None: + task = _make_mosek_task_mock() + assert solvers._choose_mosek_solution(task) is None + + +def test_choose_mosek_solution_returns_itg_for_mip() -> None: + mosek = pytest.importorskip("mosek", reason="Mosek is not installed") + task = _make_mosek_task_mock(itg_solsta=mosek.solsta.integer_optimal) + assert solvers._choose_mosek_solution(task) is mosek.soltype.itg + + +def test_choose_mosek_solution_itg_wins_over_bas_itr() -> None: + """If itg is defined we never fall back to continuous solutions.""" + mosek = pytest.importorskip("mosek", reason="Mosek is not installed") + task = _make_mosek_task_mock( + bas_solsta=mosek.solsta.optimal, + itr_solsta=mosek.solsta.optimal, + itg_solsta=mosek.solsta.integer_optimal, + ) + assert solvers._choose_mosek_solution(task) is mosek.soltype.itg + + +def test_choose_mosek_solution_picks_optimal_over_other_defined() -> None: + """Optimal beats non-optimal defined statuses regardless of iteration order.""" + mosek = pytest.importorskip("mosek", reason="Mosek is not installed") + task = _make_mosek_task_mock( + bas_solsta=mosek.solsta.unknown, + itr_solsta=mosek.solsta.optimal, + ) + assert solvers._choose_mosek_solution(task) is mosek.soltype.itr + + task = _make_mosek_task_mock( + bas_solsta=mosek.solsta.optimal, + itr_solsta=mosek.solsta.unknown, + ) + assert solvers._choose_mosek_solution(task) is mosek.soltype.bas + + +def test_choose_mosek_solution_falls_back_to_itr_when_both_non_optimal() -> None: + """Two defined-but-non-optimal solutions: prefer itr to match prior default.""" + mosek = pytest.importorskip("mosek", reason="Mosek is not installed") + task = _make_mosek_task_mock( + bas_solsta=mosek.solsta.prim_infeas_cer, + itr_solsta=mosek.solsta.dual_infeas_cer, + ) + assert solvers._choose_mosek_solution(task) is mosek.soltype.itr + + +@pytest.mark.skipif( + "mosek" not in set(solvers.licensed_solvers), reason="Mosek is not installed" +) +def test_mosek_smoke_lp(tmp_path: Path) -> None: + """End-to-end smoke test: a small bounded LP solves to a finite optimum.""" + mosek_solver = solvers.Mosek() + lp_file = tmp_path / "problem.lp" + lp_file.write_text(free_lp_problem) + sol_file = tmp_path / "solution.sol" + + result = mosek_solver.solve_problem(problem_fn=lp_file, solution_fn=sol_file) + + assert result.status.is_ok + assert result.solution is not None + import math + + assert math.isfinite(result.solution.objective) + assert result.solution.objective == pytest.approx(80.0 / 3.0, abs=1e-3) From 95310159d984fc79ea84c8e832253d195a660788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Kolouch=20Grabovsk=C3=BD?= Date: Wed, 29 Apr 2026 16:39:17 +0200 Subject: [PATCH 02/10] fix: type annotation in test helper --- test/test_solvers.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/test_solvers.py b/test/test_solvers.py index f031aff4..6cd58bfa 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -5,6 +5,7 @@ @author: sid """ +import contextlib from pathlib import Path from unittest.mock import MagicMock @@ -12,6 +13,9 @@ import pytest from test_io import model # noqa: F401 +with contextlib.suppress(ModuleNotFoundError): + import mosek + from linopy import GREATER_EQUAL, Model, solvers from linopy.constants import Result, Solution, Status from linopy.constraints import CSRConstraint @@ -574,7 +578,10 @@ def test_assign_result_without_solver_kwarg_leaves_solver_unset(self) -> None: assert m.solver is None def _make_mosek_task_mock( - *, bas_solsta=None, itr_solsta=None, itg_solsta=None + *, + bas_solsta: "mosek.solsta | None" = None, + itr_solsta: "mosek.solsta | None" = None, + itg_solsta: "mosek.solsta | None" = None, ) -> MagicMock: """Build a ``mosek.Task`` mock with controlled per-soltype statuses.""" mosek = pytest.importorskip("mosek", reason="Mosek is not installed") From af571850b85919ca54f882f5264ce326a6e184fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Kolouch=20Grabovsk=C3=BD?= Date: Wed, 20 May 2026 22:51:07 +0200 Subject: [PATCH 03/10] fix: move Mosek-scoped function to static method --- linopy/solvers.py | 106 +++++++++++++++++++++---------------------- test/test_solvers.py | 38 ++++++++-------- 2 files changed, 72 insertions(+), 72 deletions(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index 4d307c2d..3e1ea270 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2694,58 +2694,6 @@ def get_solver_solution() -> Solution: mosek_bas_re = re.compile(r" (XL|XU)\s+([^ \t]+)\s+([^ \t]+)| (LL|UL|BS)\s+([^ \t]+)") -def _choose_mosek_solution(task: mosek.Task) -> mosek.soltype | None: - """ - Pick the Mosek solution with the best status available. - - Mosek may return up to three solutions per task: interior-point - (``soltype.itr``), basic (``soltype.bas``), and integer - (``soltype.itg``). Each carries its own ``solsta``: on a numerically - marginal LP solved with the default IPM+crossover, the interior-point - solver may terminate with ``solsta.dual_infeas_cer`` while crossover - recovers ``solsta.optimal`` for the basic solution. Reading only the - interior-point solution would discard the actual optimum. - - Ranking, best to worst: ``solsta.optimal`` / ``solsta.integer_optimal`` - > any other defined status > undefined. On a tie between ``bas`` and - ``itr`` (e.g. both ``optimal``) we prefer ``itr`` to preserve historical - behaviour. If ``itg`` is defined it always wins, since integer and - continuous solutions do not coexist for a well-posed task. - - Returns ``None`` if no solution is defined at all (e.g. the optimizer - crashed before producing one). - """ - - def _is_defined(soltype: mosek.soltype) -> bool: - try: - return bool(task.solutiondef(soltype)) - except mosek.Error: - return False - - if _is_defined(mosek.soltype.itg): - return mosek.soltype.itg - - optimal_statuses = {mosek.solsta.optimal, mosek.solsta.integer_optimal} - - best: mosek.soltype | None = None - best_score = -1 - # Iterate bas first and only then itr so that on a score tie - # itr wins, preserving the historical default for the common LP case. - for candidate in [mosek.soltype.bas, mosek.soltype.itr]: - if not _is_defined(candidate): - continue - try: - solsta = task.getsolsta(candidate) - except mosek.Error: - continue - score = 1 if solsta in optimal_statuses else 0 - if score >= best_score: - best = candidate - best_score = score - - return best - - class Mosek(Solver[None]): """ Solver subclass for the Mosek solver. @@ -2919,6 +2867,58 @@ def _build_solver_model( task.putobjsense(mosek.objsense.minimize) return task + @staticmethod + def _choose_solution(task: mosek.Task) -> mosek.soltype | None: + """ + Pick the Mosek solution with the best status available. + + Mosek may return up to three solutions per task: interior-point + (``soltype.itr``), basic (``soltype.bas``), and integer + (``soltype.itg``). Each carries its own ``solsta``: on a numerically + marginal LP solved with the default IPM+crossover, the interior-point + solver may terminate with ``solsta.dual_infeas_cer`` while crossover + recovers ``solsta.optimal`` for the basic solution. Reading only the + interior-point solution would discard the actual optimum. + + Ranking, best to worst: ``solsta.optimal`` / ``solsta.integer_optimal`` + > any other defined status > undefined. On a tie between ``bas`` and + ``itr`` (e.g. both ``optimal``) we prefer ``itr`` to preserve historical + behaviour. If ``itg`` is defined it always wins, since integer and + continuous solutions do not coexist for a well-posed task. + + Returns ``None`` if no solution is defined at all (e.g. the optimizer + crashed before producing one). + """ + + def _is_defined(soltype: mosek.soltype) -> bool: + try: + return bool(task.solutiondef(soltype)) + except mosek.Error: + return False + + if _is_defined(mosek.soltype.itg): + return mosek.soltype.itg + + optimal_statuses = {mosek.solsta.optimal, mosek.solsta.integer_optimal} + + best: mosek.soltype | None = None + best_score = -1 + # Iterate bas first and only then itr so that on a score tie + # itr wins, preserving the historical default for the common LP case. + for candidate in [mosek.soltype.bas, mosek.soltype.itr]: + if not _is_defined(candidate): + continue + try: + solsta = task.getsolsta(candidate) + except mosek.Error: + continue + score = 1 if solsta in optimal_statuses else 0 + if score >= best_score: + best = candidate + best_score = score + + return best + def _run_file( self, solution_fn: Path | None = None, @@ -3105,7 +3105,7 @@ def _solve( # Inspect both bas and itr (and itg for MILPs) and pick the # solution with the best status. Reading only the interior-point # solution may discard a valid crossover optimum. - soltype = _choose_mosek_solution(m) + soltype = Mosek._choose_solution(m) if solution_fn is not None and soltype is not None: try: diff --git a/test/test_solvers.py b/test/test_solvers.py index 6cd58bfa..22b49dee 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -598,50 +598,50 @@ def _make_mosek_task_mock( return task -def test_choose_mosek_solution_prefers_basic_when_itr_is_farkas() -> None: +def test_mosek_choose_solution_prefers_basic_when_itr_is_farkas() -> None: """When the IPM ends in a Farkas certificate but crossover is optimal, pick bas.""" mosek = pytest.importorskip("mosek", reason="Mosek is not installed") task = _make_mosek_task_mock( bas_solsta=mosek.solsta.optimal, itr_solsta=mosek.solsta.dual_infeas_cer, ) - assert solvers._choose_mosek_solution(task) is mosek.soltype.bas + assert solvers.Mosek._choose_solution(task) is mosek.soltype.bas -def test_choose_mosek_solution_prefers_itr_on_tie() -> None: +def test_mosek_choose_solution_prefers_itr_on_tie() -> None: """Both bas and itr optimal: prefer itr to preserve historical default.""" mosek = pytest.importorskip("mosek", reason="Mosek is not installed") task = _make_mosek_task_mock( bas_solsta=mosek.solsta.optimal, itr_solsta=mosek.solsta.optimal, ) - assert solvers._choose_mosek_solution(task) is mosek.soltype.itr + assert solvers.Mosek._choose_solution(task) is mosek.soltype.itr -def test_choose_mosek_solution_only_itr_defined() -> None: +def test_mosek_choose_solution_only_itr_defined() -> None: mosek = pytest.importorskip("mosek", reason="Mosek is not installed") task = _make_mosek_task_mock(itr_solsta=mosek.solsta.optimal) - assert solvers._choose_mosek_solution(task) is mosek.soltype.itr + assert solvers.Mosek._choose_solution(task) is mosek.soltype.itr -def test_choose_mosek_solution_only_bas_defined() -> None: +def test_mosek_choose_solution_only_bas_defined() -> None: mosek = pytest.importorskip("mosek", reason="Mosek is not installed") task = _make_mosek_task_mock(bas_solsta=mosek.solsta.optimal) - assert solvers._choose_mosek_solution(task) is mosek.soltype.bas + assert solvers.Mosek._choose_solution(task) is mosek.soltype.bas -def test_choose_mosek_solution_returns_none_when_nothing_defined() -> None: +def test_mosek_choose_solution_returns_none_when_nothing_defined() -> None: task = _make_mosek_task_mock() - assert solvers._choose_mosek_solution(task) is None + assert solvers.Mosek._choose_solution(task) is None -def test_choose_mosek_solution_returns_itg_for_mip() -> None: +def test_mosek_choose_solution_returns_itg_for_mip() -> None: mosek = pytest.importorskip("mosek", reason="Mosek is not installed") task = _make_mosek_task_mock(itg_solsta=mosek.solsta.integer_optimal) - assert solvers._choose_mosek_solution(task) is mosek.soltype.itg + assert solvers.Mosek._choose_solution(task) is mosek.soltype.itg -def test_choose_mosek_solution_itg_wins_over_bas_itr() -> None: +def test_mosek_choose_solution_itg_wins_over_bas_itr() -> None: """If itg is defined we never fall back to continuous solutions.""" mosek = pytest.importorskip("mosek", reason="Mosek is not installed") task = _make_mosek_task_mock( @@ -649,33 +649,33 @@ def test_choose_mosek_solution_itg_wins_over_bas_itr() -> None: itr_solsta=mosek.solsta.optimal, itg_solsta=mosek.solsta.integer_optimal, ) - assert solvers._choose_mosek_solution(task) is mosek.soltype.itg + assert solvers.Mosek._choose_solution(task) is mosek.soltype.itg -def test_choose_mosek_solution_picks_optimal_over_other_defined() -> None: +def test_mosek_choose_solution_picks_optimal_over_other_defined() -> None: """Optimal beats non-optimal defined statuses regardless of iteration order.""" mosek = pytest.importorskip("mosek", reason="Mosek is not installed") task = _make_mosek_task_mock( bas_solsta=mosek.solsta.unknown, itr_solsta=mosek.solsta.optimal, ) - assert solvers._choose_mosek_solution(task) is mosek.soltype.itr + assert solvers.Mosek._choose_solution(task) is mosek.soltype.itr task = _make_mosek_task_mock( bas_solsta=mosek.solsta.optimal, itr_solsta=mosek.solsta.unknown, ) - assert solvers._choose_mosek_solution(task) is mosek.soltype.bas + assert solvers.Mosek._choose_solution(task) is mosek.soltype.bas -def test_choose_mosek_solution_falls_back_to_itr_when_both_non_optimal() -> None: +def test_mosek_choose_solution_falls_back_to_itr_when_both_non_optimal() -> None: """Two defined-but-non-optimal solutions: prefer itr to match prior default.""" mosek = pytest.importorskip("mosek", reason="Mosek is not installed") task = _make_mosek_task_mock( bas_solsta=mosek.solsta.prim_infeas_cer, itr_solsta=mosek.solsta.dual_infeas_cer, ) - assert solvers._choose_mosek_solution(task) is mosek.soltype.itr + assert solvers.Mosek._choose_solution(task) is mosek.soltype.itr @pytest.mark.skipif( From cd38b52fcd7629ae49d20447d59742bed4b88ca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Kolouch=20Grabovsk=C3=BD?= Date: Wed, 20 May 2026 22:51:35 +0200 Subject: [PATCH 04/10] fix: call _make_result instead of manual construction --- linopy/solvers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index 3e1ea270..3b6f7c62 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -3119,7 +3119,7 @@ def _solve( TerminationCondition.internal_solver_error ) status.legacy_status = condition - return Result(status, Solution()) + return self._make_result(status, None) condition = str(m.getsolsta(soltype)) termination_condition = CONDITION_MAP.get(condition, condition) From 247aeefc5466696b62cd69103c15d115227f8d8c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:31:36 +0000 Subject: [PATCH 05/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- test/test_solvers.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/test/test_solvers.py b/test/test_solvers.py index 22b49dee..7c544067 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -373,9 +373,7 @@ def test_knitro_solver_no_log(tmp_path: Path) -> None: @pytest.mark.skipif( "gurobi" not in set(solvers.licensed_solvers), reason="Gurobi is not installed" ) -def test_gurobi_environment_with_dict( - model: Model, tmp_path: Path -) -> None: # noqa: F811 +def test_gurobi_environment_with_dict(model: Model, tmp_path: Path) -> None: # noqa: F811 gurobi = solvers.Gurobi() mps_file = tmp_path / "problem.mps" @@ -401,9 +399,7 @@ def test_gurobi_environment_with_dict( @pytest.mark.skipif( "gurobi" not in set(solvers.licensed_solvers), reason="Gurobi is not installed" ) -def test_gurobi_environment_with_gurobi_env( - model: Model, tmp_path: Path -) -> None: # noqa: F811 +def test_gurobi_environment_with_gurobi_env(model: Model, tmp_path: Path) -> None: # noqa: F811 import gurobipy as gp gurobi = solvers.Gurobi() @@ -577,6 +573,7 @@ def test_assign_result_without_solver_kwarg_leaves_solver_unset(self) -> None: assert m.solver is None + def _make_mosek_task_mock( *, bas_solsta: "mosek.solsta | None" = None, From aeb0331073bb5c7c878c2b9115bf9219bfb9a0c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Kolouch=20Grabovsk=C3=BD?= Date: Wed, 20 May 2026 23:05:04 +0200 Subject: [PATCH 06/10] fix: drop deprecated function in test --- test/test_solvers.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/test_solvers.py b/test/test_solvers.py index 7c544067..a0897009 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -678,18 +678,18 @@ def test_mosek_choose_solution_falls_back_to_itr_when_both_non_optimal() -> None @pytest.mark.skipif( "mosek" not in set(solvers.licensed_solvers), reason="Mosek is not installed" ) -def test_mosek_smoke_lp(tmp_path: Path) -> None: +def test_mosek_smoke_lp() -> None: """End-to-end smoke test: a small bounded LP solves to a finite optimum.""" - mosek_solver = solvers.Mosek() - lp_file = tmp_path / "problem.lp" - lp_file.write_text(free_lp_problem) - sol_file = tmp_path / "solution.sol" + m = Model() + x = m.add_variables(name="x", lower=0) + m.add_constraints(2 * x >= 10, name="c1") + m.add_objective(x) - result = mosek_solver.solve_problem(problem_fn=lp_file, solution_fn=sol_file) + result = solvers.Solver.from_name("mosek", m).solve() assert result.status.is_ok assert result.solution is not None import math assert math.isfinite(result.solution.objective) - assert result.solution.objective == pytest.approx(80.0 / 3.0, abs=1e-3) + assert result.solution.objective == pytest.approx(5.0, abs=1e-3) From e331c78cb2a477ee1946decef53ce511eb21593d Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 27 May 2026 12:34:07 +0200 Subject: [PATCH 07/10] refactor(test): consolidate mosek tests into TestMosekChooseSolution class --- test/test_solvers.py | 228 +++++++++++++++++++++---------------------- 1 file changed, 111 insertions(+), 117 deletions(-) diff --git a/test/test_solvers.py b/test/test_solvers.py index a0897009..581eeb03 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -5,7 +5,6 @@ @author: sid """ -import contextlib from pathlib import Path from unittest.mock import MagicMock @@ -13,9 +12,6 @@ import pytest from test_io import model # noqa: F401 -with contextlib.suppress(ModuleNotFoundError): - import mosek - from linopy import GREATER_EQUAL, Model, solvers from linopy.constants import Result, Solution, Status from linopy.constraints import CSRConstraint @@ -574,122 +570,120 @@ def test_assign_result_without_solver_kwarg_leaves_solver_unset(self) -> None: assert m.solver is None -def _make_mosek_task_mock( - *, - bas_solsta: "mosek.solsta | None" = None, - itr_solsta: "mosek.solsta | None" = None, - itg_solsta: "mosek.solsta | None" = None, -) -> MagicMock: - """Build a ``mosek.Task`` mock with controlled per-soltype statuses.""" - mosek = pytest.importorskip("mosek", reason="Mosek is not installed") - - defined = { - mosek.soltype.bas: bas_solsta, - mosek.soltype.itr: itr_solsta, - mosek.soltype.itg: itg_solsta, - } - - task = MagicMock() - task.solutiondef.side_effect = lambda st: defined[st] is not None - task.getsolsta.side_effect = lambda st: defined[st] - return task - - -def test_mosek_choose_solution_prefers_basic_when_itr_is_farkas() -> None: - """When the IPM ends in a Farkas certificate but crossover is optimal, pick bas.""" - mosek = pytest.importorskip("mosek", reason="Mosek is not installed") - task = _make_mosek_task_mock( - bas_solsta=mosek.solsta.optimal, - itr_solsta=mosek.solsta.dual_infeas_cer, - ) - assert solvers.Mosek._choose_solution(task) is mosek.soltype.bas - - -def test_mosek_choose_solution_prefers_itr_on_tie() -> None: - """Both bas and itr optimal: prefer itr to preserve historical default.""" - mosek = pytest.importorskip("mosek", reason="Mosek is not installed") - task = _make_mosek_task_mock( - bas_solsta=mosek.solsta.optimal, - itr_solsta=mosek.solsta.optimal, - ) - assert solvers.Mosek._choose_solution(task) is mosek.soltype.itr - - -def test_mosek_choose_solution_only_itr_defined() -> None: - mosek = pytest.importorskip("mosek", reason="Mosek is not installed") - task = _make_mosek_task_mock(itr_solsta=mosek.solsta.optimal) - assert solvers.Mosek._choose_solution(task) is mosek.soltype.itr - - -def test_mosek_choose_solution_only_bas_defined() -> None: - mosek = pytest.importorskip("mosek", reason="Mosek is not installed") - task = _make_mosek_task_mock(bas_solsta=mosek.solsta.optimal) - assert solvers.Mosek._choose_solution(task) is mosek.soltype.bas - - -def test_mosek_choose_solution_returns_none_when_nothing_defined() -> None: - task = _make_mosek_task_mock() - assert solvers.Mosek._choose_solution(task) is None - - -def test_mosek_choose_solution_returns_itg_for_mip() -> None: - mosek = pytest.importorskip("mosek", reason="Mosek is not installed") - task = _make_mosek_task_mock(itg_solsta=mosek.solsta.integer_optimal) - assert solvers.Mosek._choose_solution(task) is mosek.soltype.itg - - -def test_mosek_choose_solution_itg_wins_over_bas_itr() -> None: - """If itg is defined we never fall back to continuous solutions.""" - mosek = pytest.importorskip("mosek", reason="Mosek is not installed") - task = _make_mosek_task_mock( - bas_solsta=mosek.solsta.optimal, - itr_solsta=mosek.solsta.optimal, - itg_solsta=mosek.solsta.integer_optimal, +mosek_installed = pytest.importorskip("mosek", reason="Mosek is not installed") + + +class TestMosekChooseSolution: + @staticmethod + def _make_task_mock( + *, + bas_solsta: "mosek_installed.solsta | None" = None, + itr_solsta: "mosek_installed.solsta | None" = None, + itg_solsta: "mosek_installed.solsta | None" = None, + ) -> MagicMock: + defined = { + mosek_installed.soltype.bas: bas_solsta, + mosek_installed.soltype.itr: itr_solsta, + mosek_installed.soltype.itg: itg_solsta, + } + task = MagicMock() + task.solutiondef.side_effect = lambda st: defined[st] is not None + task.getsolsta.side_effect = lambda st: defined[st] + return task + + @pytest.mark.parametrize( + "kwargs, expected_soltype", + [ + pytest.param( + dict( + bas_solsta=mosek_installed.solsta.optimal, + itr_solsta=mosek_installed.solsta.dual_infeas_cer, + ), + mosek_installed.soltype.bas, + id="prefers_bas_when_itr_is_farkas", + ), + pytest.param( + dict( + bas_solsta=mosek_installed.solsta.optimal, + itr_solsta=mosek_installed.solsta.optimal, + ), + mosek_installed.soltype.itr, + id="prefers_itr_on_tie", + ), + pytest.param( + dict(itr_solsta=mosek_installed.solsta.optimal), + mosek_installed.soltype.itr, + id="only_itr_defined", + ), + pytest.param( + dict(bas_solsta=mosek_installed.solsta.optimal), + mosek_installed.soltype.bas, + id="only_bas_defined", + ), + pytest.param( + dict(), + None, + id="nothing_defined", + ), + pytest.param( + dict(itg_solsta=mosek_installed.solsta.integer_optimal), + mosek_installed.soltype.itg, + id="itg_for_mip", + ), + pytest.param( + dict( + bas_solsta=mosek_installed.solsta.optimal, + itr_solsta=mosek_installed.solsta.optimal, + itg_solsta=mosek_installed.solsta.integer_optimal, + ), + mosek_installed.soltype.itg, + id="itg_wins_over_bas_itr", + ), + pytest.param( + dict( + bas_solsta=mosek_installed.solsta.unknown, + itr_solsta=mosek_installed.solsta.optimal, + ), + mosek_installed.soltype.itr, + id="optimal_itr_over_unknown_bas", + ), + pytest.param( + dict( + bas_solsta=mosek_installed.solsta.optimal, + itr_solsta=mosek_installed.solsta.unknown, + ), + mosek_installed.soltype.bas, + id="optimal_bas_over_unknown_itr", + ), + pytest.param( + dict( + bas_solsta=mosek_installed.solsta.prim_infeas_cer, + itr_solsta=mosek_installed.solsta.dual_infeas_cer, + ), + mosek_installed.soltype.itr, + id="falls_back_to_itr_when_both_non_optimal", + ), + ], ) - assert solvers.Mosek._choose_solution(task) is mosek.soltype.itg + def test_choose_solution(self, kwargs, expected_soltype) -> None: + task = self._make_task_mock(**kwargs) + assert solvers.Mosek._choose_solution(task) is expected_soltype - -def test_mosek_choose_solution_picks_optimal_over_other_defined() -> None: - """Optimal beats non-optimal defined statuses regardless of iteration order.""" - mosek = pytest.importorskip("mosek", reason="Mosek is not installed") - task = _make_mosek_task_mock( - bas_solsta=mosek.solsta.unknown, - itr_solsta=mosek.solsta.optimal, - ) - assert solvers.Mosek._choose_solution(task) is mosek.soltype.itr - - task = _make_mosek_task_mock( - bas_solsta=mosek.solsta.optimal, - itr_solsta=mosek.solsta.unknown, - ) - assert solvers.Mosek._choose_solution(task) is mosek.soltype.bas - - -def test_mosek_choose_solution_falls_back_to_itr_when_both_non_optimal() -> None: - """Two defined-but-non-optimal solutions: prefer itr to match prior default.""" - mosek = pytest.importorskip("mosek", reason="Mosek is not installed") - task = _make_mosek_task_mock( - bas_solsta=mosek.solsta.prim_infeas_cer, - itr_solsta=mosek.solsta.dual_infeas_cer, + @pytest.mark.skipif( + "mosek" not in set(solvers.licensed_solvers), + reason="Mosek is not licensed", ) - assert solvers.Mosek._choose_solution(task) is mosek.soltype.itr - + def test_smoke_lp(self) -> None: + import math -@pytest.mark.skipif( - "mosek" not in set(solvers.licensed_solvers), reason="Mosek is not installed" -) -def test_mosek_smoke_lp() -> None: - """End-to-end smoke test: a small bounded LP solves to a finite optimum.""" - m = Model() - x = m.add_variables(name="x", lower=0) - m.add_constraints(2 * x >= 10, name="c1") - m.add_objective(x) - - result = solvers.Solver.from_name("mosek", m).solve() + m = Model() + x = m.add_variables(name="x", lower=0) + m.add_constraints(2 * x >= 10, name="c1") + m.add_objective(x) - assert result.status.is_ok - assert result.solution is not None - import math + result = solvers.Solver.from_name("mosek", m).solve() - assert math.isfinite(result.solution.objective) - assert result.solution.objective == pytest.approx(5.0, abs=1e-3) + assert result.status.is_ok + assert result.solution is not None + assert math.isfinite(result.solution.objective) + assert result.solution.objective == pytest.approx(5.0, abs=1e-3) From 86dcffadc3645cfc44bc8c7613d768c17607ffc8 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 27 May 2026 12:35:37 +0200 Subject: [PATCH 08/10] udpate release notes --- doc/release_notes.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 7bd36123..1e29f6b6 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -54,6 +54,7 @@ Most users should keep calling ``model.solve(...)``. If you want more control, y * SOS constraints on masked variables no longer cause solver-specific failures (Gurobi ``IndexError``, Xpress ``?404 Invalid column number``, LP parse errors, silent set corruption). ``Model.solve()`` and ``Model.to_file()`` now raise a clear ``NotImplementedError`` referring users to `#688 `__; pass ``reformulate_sos=True`` as a workaround. * ``Model.solve(..., reformulate_sos=True)`` now actually reformulates SOS constraints even when the solver supports them natively. Previously it was silently ignored with a warning. +* Fix Mosek interface to inspect both the basic and IPM solutions and pick the one with the better status, so that an optimal crossover solution is not discarded when IPM terminates with a (near-)Farkas certificate. **Breaking Changes** @@ -113,7 +114,6 @@ Version 0.7.0 * ``add_variables`` no longer ignores ``coords`` when ``lower`` / ``upper`` are DataArrays, and handles MultiIndex coords correctly with scalar bounds. * ``Model.to_netcdf`` no longer fails on the scipy netCDF backend when variables or constraints have MultiIndex coords; level names are now serialised as a JSON string (the legacy list form remains readable). * CPLEX no longer errors on quality attributes that aren't always available. -* Fix Mosek interface to inspect both the basic and IPM solutions and pick the one with the better status, so that an optimal crossover solution is not discarded when IPM terminates with a (near-)Farkas certificate. **Breaking Changes** From c49bf8f4d3a5a3f9a3f37d3906f80b93cd534cf5 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 27 May 2026 12:44:13 +0200 Subject: [PATCH 09/10] fix(test): resolve mypy errors in mosek test class --- test/test_solvers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/test_solvers.py b/test/test_solvers.py index 581eeb03..49cada57 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -577,9 +577,9 @@ class TestMosekChooseSolution: @staticmethod def _make_task_mock( *, - bas_solsta: "mosek_installed.solsta | None" = None, - itr_solsta: "mosek_installed.solsta | None" = None, - itg_solsta: "mosek_installed.solsta | None" = None, + bas_solsta: object | None = None, + itr_solsta: object | None = None, + itg_solsta: object | None = None, ) -> MagicMock: defined = { mosek_installed.soltype.bas: bas_solsta, @@ -665,7 +665,7 @@ def _make_task_mock( ), ], ) - def test_choose_solution(self, kwargs, expected_soltype) -> None: + def test_choose_solution(self, kwargs: dict[str, object], expected_soltype: object) -> None: task = self._make_task_mock(**kwargs) assert solvers.Mosek._choose_solution(task) is expected_soltype From 99ab5485de943287aca0826cb1238111721843dc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 27 May 2026 10:44:29 +0000 Subject: [PATCH 10/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- test/test_solvers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/test_solvers.py b/test/test_solvers.py index 49cada57..3c927245 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -665,7 +665,9 @@ def _make_task_mock( ), ], ) - def test_choose_solution(self, kwargs: dict[str, object], expected_soltype: object) -> None: + def test_choose_solution( + self, kwargs: dict[str, object], expected_soltype: object + ) -> None: task = self._make_task_mock(**kwargs) assert solvers.Mosek._choose_solution(task) is expected_soltype