diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 0073594d..e55124b4 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -27,6 +27,7 @@ Upcoming Version * Add ``fix()``, ``unfix()``, and ``fixed`` to ``Variable`` and ``Variables`` for fixing variables to values via equality constraints. Supports automatic rounding for integer/binary variables. * Add ``relax()``, ``unrelax()``, and ``relaxed`` to ``Variable`` and ``Variables`` for LP relaxation of integer/binary variables. Supports partial relaxation via filtered views (e.g. ``m.variables.integers.relax()``). Semi-continuous variables raise ``NotImplementedError``. * Fix ``as_dataarray`` treating multi-index level names as extra dimensions when broadcasting a scalar against ``xarray.Coordinates``. +* 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. Version 0.6.7 diff --git a/linopy/solvers.py b/linopy/solvers.py index fb04e476..01a3fb2a 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1999,6 +1999,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. @@ -2291,25 +2343,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 7f4d55ec..24fce076 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -5,11 +5,16 @@ @author: sid """ +import contextlib from pathlib import Path +from unittest.mock import MagicMock import pytest from test_io import model # noqa: F401 +with contextlib.suppress(ModuleNotFoundError): + import mosek + from linopy import Model, solvers from linopy.solver_capabilities import SolverFeature, solver_supports @@ -218,3 +223,124 @@ def test_gurobi_environment_with_gurobi_env(model: Model, tmp_path: Path) -> Non gurobi.solve_problem(model=model, solution_fn=sol_file, env=env) assert result.status.is_ok assert log2_file.exists() + + +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_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.available_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)