Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 66 additions & 14 deletions linopy/solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
126 changes: 126 additions & 0 deletions test/test_solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Loading