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 @@ -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**

Expand Down
80 changes: 66 additions & 14 deletions linopy/solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
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,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
Expand Down Expand Up @@ -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)
Loading