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
26 changes: 26 additions & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,32 @@ Construction

solvers.Solver.from_name
solvers.Solver.from_model
solvers.Solver.from_file

Existing LP/MPS files
---------------------

Use ``Solver.from_file`` to solve an existing file without constructing a
linopy ``Model``. The input is read during ``solve()`` and remains owned by
the caller.

.. code-block:: python

from linopy.solvers import Solver

solver = Solver.from_file("highs", "problem.mps", options={"time_limit": 60})
try:
result = solver.solve()
print(result.status)
print(result.solution.objective)
finally:
solver.close()

Backend restrictions on file contents and names still apply. Without a linopy
``Model``, label-indexed primal and dual arrays may be empty. Variable values
can be accessed through the native solver model, when available, or through
a backend-supported solution file. Read native model values before calling
``close()``.

Solving
-------
Expand Down
5 changes: 5 additions & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ Release Notes
Upcoming Version
----------------

* Added ``Solver.from_file(name, problem_fn, options=...)`` for solving existing
LP/MPS files without constructing a linopy model or using deprecated solve
methods. The input file is not rewritten or deleted by the new constructor.



*Strict "v1" arithmetic semantics (opt-in)*

Expand Down
66 changes: 62 additions & 4 deletions linopy/solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,8 @@ class Solver(ABC, Generic[EnvType]):

Subclasses provide ``_build_direct`` / ``_run_direct`` (when supporting the
direct API) and ``_run_file`` (when supporting LP/MPS files). Construction
goes via :meth:`Solver.from_name` or :meth:`Solver.from_model`.
goes via :meth:`Solver.from_name`, :meth:`Solver.from_model`,
or :meth:`Solver.from_file` for existing LP/MPS files.

``track_updates`` toggles persistent-update support:

Expand Down Expand Up @@ -680,6 +681,61 @@ def from_name(
**build_kwargs,
)

@staticmethod
def from_file(
name: str,
problem_fn: Path | str,
options: dict[str, Any] | None = None,
) -> Solver:
"""
Prepare a solver for an existing LP or MPS file.

No linopy Model is required. The input file is read by the backend
when solve() is called and is not rewritten or owned by linopy.
Existing backend restrictions on file contents and names still apply.
Without a linopy Model, label-indexed primal and dual arrays may be
empty. Access variable values through the native solver model or a
backend-supported solution file.

Parameters
----------
name : str
Registered solver name.
problem_fn : Path or str
Path to an existing .lp or .mps file.
options : dict, optional
Backend solver options.

Returns
-------
Solver
A solver ready for solve(). Pass log_fn, solution_fn and other
execution arguments to solve().
"""
cls = _solver_class_for(name)
if cls is None:
raise ValueError(f"unknown solver: {name}")
if not cls.supports(SolverFeature.READ_MODEL_FROM_FILE):
raise NotImplementedError(
f"{name} does not support reading an existing problem file."
)

problem_fn = Path(problem_fn).resolve(strict=True)
if not problem_fn.is_file():
raise ValueError(f"Problem path is not a regular file: {problem_fn}")

io_api = read_io_api_from_problem_file(problem_fn)
if io_api not in {"lp", "mps"}:
raise ValueError("Expected an existing .lp or .mps file.")

instance = cls(
model=None,
io_api=io_api,
options=dict(options) if options is not None else {},
)
instance._problem_fn = problem_fn
return instance

@classmethod
def from_model(
cls,
Expand Down Expand Up @@ -1003,8 +1059,10 @@ def solve_problem(
"""Deprecated. Use ``Solver.from_name(...).solve(...)`` or ``Model.solve(...)``."""
warnings.warn(
"Solver.solve_problem is deprecated and will be removed in a future "
"release. Use Solver.from_name(name, model, ...).solve(...) or "
"Model.solve(...) instead.",
"release. For existing files, use "
"Solver.from_file(name, problem_fn, ...).solve(...). "
"For models, use Solver.from_name(name, model, ...).solve(...) "
"or Model.solve(...) instead.",
DeprecationWarning,
stacklevel=2,
)
Expand Down Expand Up @@ -1085,7 +1143,7 @@ def solve_problem_from_file(
"""Deprecated shim that caches ``problem_fn`` and runs via ``_run_file``."""
warnings.warn(
"Solver.solve_problem_from_file is deprecated and will be removed in a "
"future release. Use Solver.from_name(name, model, problem_fn=..., ...)"
"future release. Use Solver.from_file(name, problem_fn, ...)"
".solve(...) instead.",
DeprecationWarning,
stacklevel=2,
Expand Down
137 changes: 137 additions & 0 deletions test/test_solver_from_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Tests for solving existing problem files without a linopy Model."""

import warnings
from pathlib import Path

import pytest

from linopy.solvers import Highs, Solver

pytestmark = pytest.mark.skipif(
not Highs.is_available(), reason="HiGHS is not installed"
)

LP = """Minimize
obj: x0
Subject To
c0: x0 >= 2
Bounds
0 <= x0 <= 10
End
"""

MPS = """NAME SIMPLE
ROWS
N obj
G c0
COLUMNS
x0 obj 1
x0 c0 1
RHS
rhs c0 2
BOUNDS
UP bnd x0 10
ENDATA
"""


@pytest.mark.parametrize("suffix,content", [("lp", LP), ("mps", MPS)])
@pytest.mark.parametrize("as_string", [False, True])
def test_from_file_solves_existing_input(
tmp_path: Path, suffix: str, content: str, as_string: bool
) -> None:
problem = tmp_path / f"problem.{suffix}"
problem.write_text(content)
original = problem.read_bytes()
options = {"time_limit": 60.0}
log = tmp_path / "solver.log"

with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
solver = Solver.from_file(
"highs", str(problem) if as_string else problem, options=options
)
try:
assert solver.model is None
assert solver.solver_model is None

result = solver.solve(log_fn=log)

assert result.solution is not None
assert result.status.status.value == "ok"
assert result.status.termination_condition.value == "optimal"
assert result.solution.objective == pytest.approx(2.0)
native_solution = result.solver_model.getSolution()
assert native_solution.col_value[0] == pytest.approx(2.0)
assert result.solver_name == "highs"
_, time_limit = result.solver_model.getOptionValue("time_limit")
assert time_limit == 60.0
assert log.exists()
assert options == {"time_limit": 60.0}
finally:
solver.close()

assert problem.read_bytes() == original


def test_from_file_unknown_solver(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="unknown solver"):
Solver.from_file("not_a_real_solver", tmp_path / "problem.lp")


def test_from_file_missing_input(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError):
Solver.from_file("highs", tmp_path / "missing.lp")


def test_from_file_rejects_directory(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="regular file"):
Solver.from_file("highs", tmp_path)


def test_from_file_rejects_unknown_format(tmp_path: Path) -> None:
problem = tmp_path / "problem.txt"
problem.write_text(LP)
with pytest.raises(ValueError, match=r"\.lp or \.mps"):
Solver.from_file("highs", problem)


@pytest.mark.parametrize("suffix,content", [("lp", LP), ("mps", MPS)])
@pytest.mark.parametrize("external_names", [False, True])
def test_from_file_matches_legacy_api(
tmp_path: Path, suffix: str, content: str, external_names: bool
) -> None:
if external_names:
content = content.replace("x0", "generation").replace("c0", "demand")
problem = tmp_path / f"problem.{suffix}"
problem.write_text(content)
original = problem.read_bytes()

legacy = Highs()
current = Solver.from_file("highs", problem)
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
expected = legacy.solve_problem(problem_fn=problem)

with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
actual = current.solve()

assert actual.solution is not None
assert expected.solution is not None
assert actual.status.status == expected.status.status
assert (
actual.status.termination_condition == expected.status.termination_condition
)
assert actual.solution.objective == pytest.approx(expected.solution.objective)
assert actual.solver_model.getSolution().col_value == pytest.approx(
expected.solver_model.getSolution().col_value
)
assert actual.solution.primal == pytest.approx(expected.solution.primal)
assert actual.solution.dual == pytest.approx(expected.solution.dual)
finally:
current.close()
legacy.close()

assert problem.read_bytes() == original
Loading