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..3b6f7c62 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -2867,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, @@ -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 = Mosek._choose_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 self._make_result(status, None) + 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..a0897009 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -5,12 +5,17 @@ @author: sid """ +import contextlib from pathlib import Path +from unittest.mock import MagicMock import numpy as np 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 @@ -567,3 +572,124 @@ 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: "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, + ) + assert solvers.Mosek._choose_solution(task) is mosek.soltype.itg + + +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, + ) + assert solvers.Mosek._choose_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() -> 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() + + 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(5.0, abs=1e-3)