diff --git a/doc/api.rst b/doc/api.rst index f0afc322..1ce071ac 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -70,6 +70,8 @@ Modifying a model model.Model.remove_objective model.Model.remove_sos_constraints model.Model.copy + model.Model.apply_sos_reformulation + model.Model.undo_sos_reformulation model.Model.reformulate_sos_constraints Solving @@ -498,10 +500,75 @@ Type aliases Solvers ======== +The stateful :class:`~linopy.solvers.Solver` instance owns the solver-side +model and exposes a two-step :meth:`~linopy.solvers.Solver.from_name` / +:meth:`~linopy.solvers.Solver.solve` workflow. :meth:`Model.solve` is a +thin wrapper around it. + +.. autosummary:: + :toctree: generated/ + + solvers.Solver + +Construction +------------ + +.. autosummary:: + :toctree: generated/ + + solvers.Solver.from_name + solvers.Solver.from_model + +Solving +------- + +.. autosummary:: + :toctree: generated/ + + solvers.Solver.solve + solvers.Solver.update_solver_model + solvers.Solver.close + +Post-solve state +---------------- + +.. autosummary:: + :toctree: generated/ + + solvers.Solver.status + solvers.Solver.solution + solvers.Solver.report + solvers.Solver.solver_model + +Capabilities +------------ + +.. autosummary:: + :toctree: generated/ + + solvers.Solver.is_available + solvers.Solver.license_status + solvers.Solver.supports + solvers.Solver.supported_features + solvers.Solver.runtime_features + +Discovery +--------- + .. autosummary:: :toctree: generated/ solvers.available_solvers + solvers.licensed_solvers + solvers.SolverFeature + solvers.LicenseStatus + +Implementations +--------------- + +.. autosummary:: + :toctree: generated/ + solvers.CBC solvers.COPT solvers.Cplex @@ -529,7 +596,10 @@ Solver status and result types ============================== Types returned by or compared against :attr:`Model.status`, -:attr:`Model.termination_condition`, and :attr:`Model.solution`. +:attr:`Model.termination_condition`, and :attr:`Model.solution`, plus +:class:`~linopy.constants.SolverReport` surfaced on +:attr:`Solver.report ` and +:attr:`Result.report `. .. autosummary:: :toctree: generated/ @@ -538,6 +608,7 @@ Types returned by or compared against :attr:`Model.status`, constants.TerminationCondition constants.Status constants.Solution + constants.SolverReport constants.Result diff --git a/doc/index.rst b/doc/index.rst index 39846607..0a589fc3 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -136,6 +136,7 @@ This package is published under MIT license. :maxdepth: 2 :caption: Solving + using-solvers solve-on-remote solve-on-oetc gpu-acceleration diff --git a/doc/sos-constraints.rst b/doc/sos-constraints.rst index caa4b5e5..8d1ede63 100644 --- a/doc/sos-constraints.rst +++ b/doc/sos-constraints.rst @@ -268,12 +268,13 @@ as binary + linear constraints using the Big-M method. .. code-block:: python - # Automatic reformulation during solve + # Automatic reformulation during solve (apply / undo bracketed by Model.solve) m.solve(solver_name="highs", reformulate_sos=True) - # Or reformulate manually - m.reformulate_sos_constraints() + # Or stage the reformulation manually — e.g. to inspect or export the MILP + m.apply_sos_reformulation() m.solve(solver_name="highs") + m.undo_sos_reformulation() **Requirements:** diff --git a/doc/using-solvers.nblink b/doc/using-solvers.nblink new file mode 100644 index 00000000..9b1f2781 --- /dev/null +++ b/doc/using-solvers.nblink @@ -0,0 +1,3 @@ +{ + "path": "../examples/using-solvers.ipynb" +} diff --git a/examples/create-a-model.ipynb b/examples/create-a-model.ipynb index b6fc9705..331e0700 100644 --- a/examples/create-a-model.ipynb +++ b/examples/create-a-model.ipynb @@ -30,11 +30,11 @@ }, { "cell_type": "code", - "execution_count": null, "id": "dramatic-cannon", "metadata": {}, + "source": [], "outputs": [], - "source": [] + "execution_count": null }, { "attachments": {}, @@ -49,15 +49,15 @@ }, { "cell_type": "code", - "execution_count": null, "id": "technical-conducting", "metadata": {}, - "outputs": [], "source": [ "from linopy import Model\n", "\n", "m = Model()" - ] + ], + "outputs": [], + "execution_count": null }, { "attachments": {}, @@ -83,14 +83,14 @@ }, { "cell_type": "code", - "execution_count": null, "id": "protecting-power", "metadata": {}, - "outputs": [], "source": [ "x = m.add_variables(lower=0, name=\"x\")\n", "y = m.add_variables(lower=0, name=\"y\");" - ] + ], + "outputs": [], + "execution_count": null }, { "attachments": {}, @@ -103,13 +103,13 @@ }, { "cell_type": "code", - "execution_count": null, "id": "virtual-anxiety", "metadata": {}, - "outputs": [], "source": [ "x" - ] + ], + "outputs": [], + "execution_count": null }, { "attachments": {}, @@ -127,13 +127,13 @@ }, { "cell_type": "code", - "execution_count": null, "id": "fbb46cad", "metadata": {}, - "outputs": [], "source": [ "3 * x + 7 * y >= 10" - ] + ], + "outputs": [], + "execution_count": null }, { "attachments": {}, @@ -146,13 +146,13 @@ }, { "cell_type": "code", - "execution_count": null, "id": "60f41b76", "metadata": {}, - "outputs": [], "source": [ "3 * x + 7 * y - 10 >= 0" - ] + ], + "outputs": [], + "execution_count": null }, { "attachments": {}, @@ -167,14 +167,14 @@ }, { "cell_type": "code", - "execution_count": null, "id": "hollywood-production", "metadata": {}, - "outputs": [], "source": [ "m.add_constraints(3 * x + 7 * y >= 10)\n", "m.add_constraints(5 * x + 2 * y >= 3);" - ] + ], + "outputs": [], + "execution_count": null }, { "attachments": {}, @@ -189,13 +189,13 @@ }, { "cell_type": "code", - "execution_count": null, "id": "overall-exhibition", "metadata": {}, - "outputs": [], "source": [ "m.add_objective(x + 2 * y)" - ] + ], + "outputs": [], + "execution_count": null }, { "attachments": {}, @@ -210,13 +210,13 @@ }, { "cell_type": "code", - "execution_count": null, "id": "pressing-copying", "metadata": {}, - "outputs": [], "source": [ "m.solve(solver_name=\"highs\", output_flag=False)" - ] + ], + "outputs": [], + "execution_count": null }, { "attachments": {}, @@ -229,23 +229,23 @@ }, { "cell_type": "code", - "execution_count": null, "id": "electric-duration", "metadata": {}, - "outputs": [], "source": [ "x.solution" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "id": "e6d31751", "metadata": {}, - "outputs": [], "source": [ "y.solution" - ] + ], + "outputs": [], + "execution_count": null }, { "attachments": {}, @@ -255,6 +255,59 @@ "source": [ "Well done! You solved your first linopy model!" ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "f2dc93fa", + "metadata": {}, + "source": [ + "After solving, the solver instance stays attached to the model as `model.solver`, including a small report (runtime, MIP gap, dual bound, iterations) on solvers that populate it:" + ] + }, + { + "cell_type": "code", + "id": "21edcd3f", + "metadata": {}, + "source": [ + "m.solver" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "91a26989", + "metadata": {}, + "source": [ + "m.solver.report" + ], + "outputs": [], + "execution_count": null + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "58965d32", + "source": "Some solvers hold a license while the underlying handle is alive. Release it with `solver.close()` (or `model.solver = None` to also detach the wrapper):", + "metadata": {} + }, + { + "cell_type": "code", + "id": "d9c736c6", + "source": "m.solver.close()", + "metadata": {}, + "outputs": [], + "execution_count": null + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "513277f1", + "metadata": {}, + "source": [ + "For more on choosing a solver, the construct-then-solve API, and querying solver capabilities, see [Using solvers](using-solvers.ipynb)." + ] } ], "metadata": { diff --git a/examples/using-solvers.ipynb b/examples/using-solvers.ipynb new file mode 100644 index 00000000..8975c66d --- /dev/null +++ b/examples/using-solvers.ipynb @@ -0,0 +1,402 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "id": "intro-md", + "metadata": {}, + "source": [ + "# Using Solvers\n", + "\n", + "Linopy hands the model off to a solver backend \u2014 HiGHS, Gurobi, CPLEX, CBC, GLPK, SCIP, Xpress, MOSEK, MindOpt, COPT, Knitro, or the GPU solver cuPDLPx. This notebook walks through:\n", + "\n", + "- the standard ``model.solve(...)`` workflow,\n", + "- inspecting the solver afterwards via ``model.solver`` and ``SolverReport``,\n", + "- the lower-level *construct-then-solve* API for advanced use,\n", + "- listing installed and licensed solvers." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "setup-md", + "metadata": {}, + "source": [ + "## A Small Example Model\n", + "\n", + "We'll use a tiny LP throughout the notebook. Minimize $x + 2y$ subject to $x, y \\ge 0$, $3x + 7y \\ge 10$, $5x + 2y \\ge 3$." + ] + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "import linopy\n", + "from linopy import Model\n", + "\n", + "\n", + "def build_model():\n", + " m = Model()\n", + " x = m.add_variables(lower=0, name=\"x\")\n", + " y = m.add_variables(lower=0, name=\"y\")\n", + " m.add_constraints(3 * x + 7 * y >= 10)\n", + " m.add_constraints(5 * x + 2 * y >= 3)\n", + " m.add_objective(x + 2 * y)\n", + " return m" + ], + "id": "e74c99fa8894ef2b", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## One-step Solving\n", + "\n", + "``model.solve`` picks the first available solver, runs it, writes the solution back into the variables, and returns a ``(status, termination_condition)`` tuple. You can specify which solver you want." + ], + "id": "2b5c1da82d634aa0" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "m = build_model()\n", + "status, termination = m.solve(solver_name=\"highs\", output_flag=False)\n", + "status, termination" + ], + "id": "684bb4058e8d7609", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": "m.objective.value", + "id": "befb5257295d46e1", + "outputs": [ + { + "data": { + "text/plain": [ + "2.862068965517241" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": "m.solution.to_pandas()", + "id": "581ecf74cb958ee8", + "outputs": [ + { + "data": { + "text/plain": [ + "x 0.034483\n", + "y 1.413793\n", + "dtype: float64" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": null + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "40d9d672a46a29a6", + "metadata": {}, + "source": [ + "## After Solving\n", + "\n", + "After ``model.solve(...)`` the solver instance stays attached to the model as ``model.solver``. You can read off the solver name, the native solver model, the status and \u2014 new in this release \u2014 a ``SolverReport`` with runtime, MIP gap, dual (best) bound, and iteration counts." + ] + }, + { + "cell_type": "code", + "id": "9354f740ecb5c7f2", + "metadata": {}, + "source": [ + "m.solver" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "id": "inspect-report", + "metadata": {}, + "source": [ + "m.solver.report" + ], + "outputs": [], + "execution_count": null + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "report-md", + "metadata": {}, + "source": [ + "Note that not every backend fills in every field of ``SolverReport`` \u2014 if a solver doesn't expose a value it stays ``None``. ``mip_gap`` and ``dual_bound`` are most informative on MIPs." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "close-md", + "metadata": {}, + "source": [ + "Some solvers (Gurobi, MOSEK, \u2026) hold a license while the underlying handle is alive. You can release it explicitly:" + ] + }, + { + "cell_type": "code", + "id": "close-code", + "metadata": {}, + "source": [ + "m.solver.close() # frees the native handle (and license)\n", + "# or, to also detach the wrapper:\n", + "m.solver = None\n", + "m.solver, m.solver_name" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Building the Solver\n", + "\n", + "For most users ``model.solve(...)`` is enough. If you want more control \u2014 e.g. to adjust the native solver model before running it, or to obtain the ``Result`` object directly \u2014 you can build the solver in one step and run it in another:" + ], + "id": "cf5d08bbeec2ff41" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "from linopy.solvers import Solver\n", + "\n", + "m = build_model()\n", + "solver = Solver.from_name(\"highs\", m, io_api=\"direct\", options={\"output_flag\": False})\n", + "solver" + ], + "id": "e8a0a480f6457a04", + "outputs": [], + "execution_count": null + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "twostep-md2", + "metadata": {}, + "source": [ + "``solver.solver_model`` is the native solver handle \u2014 here a ``highspy.Highs`` instance. You could tweak it directly before running:" + ] + }, + { + "metadata": {}, + "cell_type": "code", + "source": "solver.solver_model", + "id": "1e633b4b0a3c072d", + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "result = solver.solve()\n", + "result" + ], + "id": "9689b48ba6c72a66", + "outputs": [], + "execution_count": null + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "twostep-md3", + "metadata": {}, + "source": "``Result`` carries the status, solution, solver name, and report. Writing it back into the `Model`, combining numeric values with labels and coordinates, is a separate call:" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "m.assign_result(result)\n", + "m.objective.value" + ], + "id": "27f1a08c5e01613b", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": "m.solution.to_pandas()", + "id": "3cbf1737555cec1f", + "outputs": [ + { + "data": { + "text/plain": [ + "x 0.034483\n", + "y 1.413793\n", + "dtype: float64" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": null + }, + { + "cell_type": "markdown", + "id": "fadd48c0", + "source": "## Model Transformations\n\n**Model transformations live on the ``Model``, not the ``Solver``.** A ``Solver`` only declares which features it supports and raises during ``_build()`` if it can't handle the model it's been handed; it never mutates the model. The transformations currently exposed:\n\n| Transformation | Methods on ``Model`` | Reversible? |\n|---|---|---|\n| SOS reformulation (rewrite SOS constraints as Big-M binary + linear) | ``model.apply_sos_reformulation()`` / ``model.undo_sos_reformulation()`` | yes |\n| Drop zero-coefficient terms | ``model.constraints.sanitize_zeros()`` | one-way |\n| Replace \u00b1inf bounds in constraints | ``model.constraints.sanitize_infinities()`` | one-way |\n\n``model.solve(reformulate_sos=True, sanitize_zeros=True, sanitize_infinities=True)`` is a convenience that **brackets** these around the one-shot solve (and undoes the SOS reformulation afterwards). The two-step ``Solver`` API does **not** do this for you \u2014 when you go through ``Solver.from_name(...).solve()``, you call the transformations yourself first, and use ``try/finally`` to keep the model in a known state if the solve raises:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "836fe7b2", + "source": "import pandas as pd\n\nm = Model()\ni = pd.Index([0, 1, 2], name=\"i\")\nx = m.add_variables(lower=0, upper=1, coords=[i], name=\"x\")\nm.add_sos_constraints(x, sos_type=1, sos_dim=\"i\")\nm.add_objective(x.sum(), sense=\"max\")\n\nm.constraints.sanitize_zeros()\nm.constraints.sanitize_infinities()\n\nm.apply_sos_reformulation()\ntry:\n solver = Solver.from_name(\n \"highs\", m, io_api=\"direct\", options={\"output_flag\": False}\n )\n result = solver.solve()\n m.assign_result(result)\nfinally:\n m.undo_sos_reformulation() # restore original SOS form on the Model\n\nlist(m.variables.sos)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": [ + "## Available Solvers\n", + "\n", + "Two registries are exposed at the top level:\n", + "\n", + "- ``linopy.available_solvers`` \u2014 solvers whose Python package or binary is **installed**. Cheap; does not acquire a license.\n", + "- ``linopy.licensed_solvers`` \u2014 the subset that currently passes a **license** probe. Useful in tests or to pick a solver at runtime." + ], + "id": "1b9f8fe21c18808a" + }, + { + "metadata": {}, + "cell_type": "code", + "source": "list(linopy.available_solvers)", + "id": "327a12ffc10ab9eb", + "outputs": [ + { + "data": { + "text/plain": [ + "['gurobi', 'highs', 'cbc', 'scip', 'cplex', 'xpress', 'mosek', 'mindopt']" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": "list(linopy.licensed_solvers)", + "id": "6c1fc10d201b5b01", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Set parameter Username\n", + "Academic license - for non-commercial use only - expires 2026-12-18\n", + "MindOpt 2.2.0 | 2e28db43, Aug 29 2025, 14:27:12 | arm64 - macOS 26.2\n" + ] + }, + { + "data": { + "text/plain": [ + "['gurobi', 'highs', 'cbc', 'scip', 'cplex', 'xpress', 'mosek']" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Start license validation (current time : 18-MAY-2026 11:46:49 UTC+0200).\n", + "[WARN ] No license file is found.\n", + "[ERROR] No valid license was found. Please visit https://opt.aliyun.com/doc/latest/en/html/installation/license.html to apply for and set up your license.\n", + "License validation terminated. Time : 0.000s\n", + "\n" + ] + } + ], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "markdown", + "source": "Both are lazy and refreshable \u2014 call ``linopy.available_solvers.refresh()`` after installing or licensing a new solver in the same process. For a per-solver probe use ``SolverClass.license_status()``, which returns a ``LicenseStatus`` dataclass:", + "id": "e1296cdb53e5a857" + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "from linopy.solvers import Highs\n", + "\n", + "Highs.license_status()" + ], + "id": "116addf55033d51", + "outputs": [], + "execution_count": null + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}