From f0db9b7193af608be549bb10dd7dfcd17e0af7e9 Mon Sep 17 00:00:00 2001 From: Daniele Lerede Date: Wed, 30 Sep 2026 12:44:50 +0200 Subject: [PATCH 1/2] Add public Solver.from_file API for existing LP and MPS files --- doc/api.rst | 26 +++++++ doc/release_notes.rst | 5 ++ linopy/solvers.py | 66 ++++++++++++++++- test/test_solver_from_file.py | 134 ++++++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 test/test_solver_from_file.py diff --git a/doc/api.rst b/doc/api.rst index c27caebd..e1cb8ea8 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -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 ------- diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 42e8bee4..e90c2767 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -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)* diff --git a/linopy/solvers.py b/linopy/solvers.py index 6e1ec74d..cf068219 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -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: @@ -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, @@ -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, ) @@ -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, diff --git a/test/test_solver_from_file.py b/test/test_solver_from_file.py new file mode 100644 index 00000000..8285776d --- /dev/null +++ b/test/test_solver_from_file.py @@ -0,0 +1,134 @@ +"""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.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.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 From df2e7f6f380ef0133d93adb6ff481c5dfc3f704a Mon Sep 17 00:00:00 2001 From: Daniele Lerede Date: Wed, 30 Sep 2026 12:59:21 +0200 Subject: [PATCH 2/2] Check optional solutions before accessing results in tests --- test/test_solver_from_file.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/test_solver_from_file.py b/test/test_solver_from_file.py index 8285776d..ee3ecc10 100644 --- a/test/test_solver_from_file.py +++ b/test/test_solver_from_file.py @@ -57,6 +57,7 @@ def test_from_file_solves_existing_input( 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) @@ -117,6 +118,8 @@ def test_from_file_matches_legacy_api( 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