Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
daadcae
refactor(remote): Oetc/SSH as standalone classes, not Solver subclasses
FBumann May 19, 2026
f8677e8
refactor(remote): delegate OetcHandler.solve_on_oetc to Oetc.solve, d…
FBumann May 19, 2026
9061ac6
docs(release): add remote-transport entry with migration guidance
FBumann May 19, 2026
0dd55ed
feat(ssh): SshSettings.setup_commands + rewrite SSH example notebook
FBumann May 19, 2026
07701c8
docs(examples): rewrite OETC notebook for new API
FBumann May 19, 2026
01b407d
ci: retrigger docs build
FBumann May 19, 2026
ddce083
docs(examples): mark OETC/SSH notebooks as nbsphinx execute=never
FBumann May 19, 2026
3ce6e28
refactor(oetc): fold OetcCredentials into OetcSettings
FBumann May 19, 2026
4a9bf2b
docs(release): note OetcCredentials deprecation
FBumann May 19, 2026
a853238
docs(examples): merge OETC and SSH notebooks into one remote-machines…
FBumann May 19, 2026
1c11181
docs(examples): clean up OETC cell and drop comparison table
FBumann May 19, 2026
2e1d8a7
refactor(extras): rename pip extra `remote` to `ssh`
FBumann May 19, 2026
2562acb
docs(release): note narrower SSH surface vs RemoteHandler
FBumann May 19, 2026
860f0d3
docs(release): trim SSH-surface note
FBumann May 19, 2026
13f40c3
test+ci: add transport-class tests, fix notebook skip-list
FBumann May 19, 2026
0fa2769
fix(test+docs): mypy on test_remotes.py, drop 'Option N:' from notebo…
FBumann May 19, 2026
e5531bd
docs(api): list new remote classes and settings in the API reference
FBumann May 19, 2026
c93d792
Merge branch 'master' into refactor/oetc-as-solver
FBumann May 20, 2026
07e6ff9
docs(remote): say "the solver" instead of "inner solver" in user-faci…
FBumann May 20, 2026
bfc38eb
docs(remote): fix stale SSH cross-reference in SshSettings docstring
FBumann May 20, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/test-notebooks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ jobs:

# Skip notebooks that require credentials or special setup
case "$name" in
solve-on-oetc.ipynb|solve-on-remote.ipynb)
echo "Skipping $name (requires credentials or special setup)"
remote-machines.ipynb)
echo "Skipping $name (requires credentials or remote machine)"
continue
;;
esac
Expand Down
9 changes: 9 additions & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -519,10 +519,19 @@ Solvers
Remote solving
==============

Solve a model on a remote machine via SSH or on the OET Cloud (OETC).
See :doc:`remote-machines` for usage.

.. autosummary::
:toctree: generated/

remote.SSH
remote.SshSettings
remote.Oetc
remote.OetcSettings
remote.RemoteHandler
remote.OetcHandler
remote.OetcCredentials


Solver status and result types
Expand Down
3 changes: 1 addition & 2 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,7 @@ This package is published under MIT license.
:maxdepth: 2
:caption: Solving

solve-on-remote
solve-on-oetc
remote-machines
gpu-acceleration

.. toctree::
Expand Down
31 changes: 31 additions & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,39 @@ Most users should keep calling ``model.solve(...)``. If you want more control, y
* Xpress now supports ``io_api="direct"``: the linopy model is loaded via the native ``loadproblem`` array API instead of being serialised through an LP/MPS file, with SOS constraints attached in-place. Adds ``model.to_xpress()`` matching the existing ``to_gurobipy`` / ``to_highspy`` / ``to_mosek`` helpers.
* Writing the solution back to the model after solving is faster: it no longer rebuilds the constraint matrix, and now uses positional (rather than label-based) indexing — roughly 2× faster overall.

*Remote solves*

* Pass ``remote=`` to ``Model.solve`` to run the solver on a remote worker:

.. code-block:: python

m.solve("gurobi", remote=OetcSettings(...), Method=2)
m.solve("highs", remote=SshSettings(hostname=...), presolve="on")

``solver_name`` and ``**solver_options`` work the same as for local solves; ``remote=`` selects *where* to run. After the call, ``model.remote`` holds the remote instance (mirrors :attr:`Model.solver`).
* ``SshSettings.setup_commands: list[str]`` — shell commands run on the remote before the solve, e.g. ``setup_commands=["conda activate linopy-env"]``.

**Deprecations**

* ``Solver.solve_problem``, ``Solver.solve_problem_from_model``, and ``Solver.solve_problem_from_file`` still work but emit a ``DeprecationWarning``. Use ``Solver.from_name(...).solve()`` (or simply ``model.solve(...)``) instead. They will be removed in a future release.
* ``linopy.remote.OetcHandler`` and ``linopy.remote.RemoteHandler`` are deprecated. Construction emits a ``DeprecationWarning``; the ``solve_on_oetc`` / ``solve_on_remote`` return contracts are unchanged. Migrate:

.. code-block:: python

# Before
handler = OetcHandler(
OetcSettings(credentials=OetcCredentials(email=..., password=...), ...)
)
solved = handler.solve_on_oetc(m, TimeLimit=100)

# After
m.solve(
"gurobi", remote=OetcSettings(email=..., password=..., ...), TimeLimit=100
)

Passing an existing handler via ``Model.solve(remote=handler, ...)`` is also deprecated — pass the settings dataclass instead.
* ``linopy.remote.OetcCredentials`` is deprecated. Pass ``email`` and ``password`` directly to :class:`OetcSettings` instead of wrapping them. The ``OetcSettings(credentials=OetcCredentials(...))`` shape still works for one deprecation cycle and emits a ``DeprecationWarning``.
* :class:`linopy.remote.SSH` only exposes ``solve(model)``. For env activation use ``SshSettings.setup_commands``; for arbitrary remote shell commands, drop to :class:`RemoteHandler` (during deprecation) or paramiko directly.

**Bug Fixes**

Expand All @@ -60,6 +90,7 @@ Most users should keep calling ``model.solve(...)``. If you want more control, y
* ``available_solvers`` now lists all *installed* solvers, even ones without a working license. If you used it to decide "can I actually solve with X?", switch to ``linopy.licensed_solvers`` or ``SolverClass.license_status()``.
* ``Model.solver_model`` and ``Model.solver_name`` are now read-only properties that delegate to ``model.solver``. You can't reassign them (only ``= None`` is allowed, which closes the solver), and ``solver_name`` is ``None`` before the first solve.
* ``result.solution.primal`` and ``result.solution.dual`` are now ``numpy`` arrays indexed by linopy's integer labels (with ``NaN`` for slots without a value), instead of pandas Series keyed by variable/constraint name. If you accessed them by name, use ``model.variables[name].solution`` (or ``model.constraints[name].dual``) instead.
* The pip extra ``linopy[remote]`` has been renamed to ``linopy[ssh]`` to match what it installs (only ``paramiko``, for SSH transport — OETC has its own ``linopy[oetc]`` extra). ``linopy[remote]`` no longer exists; update your install commands.

**Internal**

Expand Down
3 changes: 3 additions & 0 deletions doc/remote-machines.nblink
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"path": "../examples/remote-machines.ipynb"
}
3 changes: 0 additions & 3 deletions doc/solve-on-oetc.nblink

This file was deleted.

3 changes: 0 additions & 3 deletions doc/solve-on-remote.nblink

This file was deleted.

4 changes: 2 additions & 2 deletions doc/user-guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ Where to go next
:doc:`piecewise-linear-constraints`, and the
:doc:`testing-framework` for asserting structural properties of a
model.
- **Solving** — :doc:`solve-on-remote` (SSH),
:doc:`solve-on-oetc` (OET Cloud), :doc:`gpu-acceleration` (cuPDLPx).
- **Solving** — :doc:`remote-machines` (SSH or OET Cloud),
:doc:`gpu-acceleration` (cuPDLPx).
- **Troubleshooting** — :doc:`infeasible-model` (diagnosing infeasible
problems), :doc:`gurobi-double-logging` (and other solver quirks).
- **Reference** — the full :doc:`api` listing.
238 changes: 238 additions & 0 deletions examples/remote-machines.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "0",
"metadata": {},
"source": [
"# Remote machines\n",
"\n",
"linopy can ship your model to a remote machine, run a solver there, and pull the solved model back. Two transports are supported:\n",
"\n",
"- **SSH** — connect to a server you own (or have access to) over SSH.\n",
"- **OETC** — submit jobs to [OET Cloud](https://open-energy-transition.org/), a managed optimization service.\n",
"\n",
"Both share the same entry point on `Model.solve`:\n",
"\n",
"```python\n",
"m.solve(\"gurobi\", remote=<Settings>, **solver_options)\n",
"```\n",
"\n",
"`solver_name` and `**solver_options` work exactly like a local solve; `remote=` selects *where* to run. After the call, `model.remote` holds the transport instance for post-solve introspection (mirrors `model.solver`)."
]
},
{
"cell_type": "markdown",
"id": "1",
"metadata": {},
"source": [
"> **Note:** This notebook is not executed during the documentation build — it requires either SSH access to a remote server or OETC credentials."
]
},
{
"cell_type": "markdown",
"id": "2",
"metadata": {},
"source": [
"## Create a model\n",
"\n",
"Build the model locally as usual:"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3",
"metadata": {},
"outputs": [],
"source": [
"from numpy import arange\n",
"from xarray import DataArray\n",
"\n",
"from linopy import Model\n",
"\n",
"N = 10\n",
"m = Model()\n",
"coords = [arange(N), arange(N)]\n",
"x = m.add_variables(coords=coords, name=\"x\")\n",
"y = m.add_variables(coords=coords, name=\"y\")\n",
"m.add_constraints(x - y >= DataArray(arange(N)))\n",
"m.add_constraints(x + y >= 0)\n",
"m.add_objective((2 * x + y).sum())\n",
"m"
]
},
{
"cell_type": "markdown",
"id": "4",
"metadata": {},
"source": [
"## SSH\n",
"\n",
"**What you need**\n",
"\n",
"- `uv pip install \"linopy[ssh]\"` locally (pulls in `paramiko`).\n",
"- A remote server with linopy and a solver installed (e.g. in a conda environment).\n",
"- SSH access to that machine (key-based auth recommended).\n",
"\n",
"Build an `SshSettings` and pass it as `remote=`. Use `setup_commands` to activate environments or export variables on the remote shell before the solve."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "5",
"metadata": {},
"outputs": [],
"source": [
"from linopy.remote import SshSettings\n",
"\n",
"ssh_settings = SshSettings(\n",
" hostname=\"your.host.de\",\n",
" username=\"username\",\n",
" # password=\"...\", # not needed when SSH keys are autodetected\n",
" setup_commands=[\"conda activate linopy-env\"],\n",
")\n",
"\n",
"m.solve(\"gurobi\", remote=ssh_settings)\n",
"m.solution"
]
},
{
"cell_type": "markdown",
"id": "6",
"metadata": {},
"source": [
"## OETC\n",
"\n",
"**What you need**\n",
"\n",
"- `uv pip install \"linopy[oetc]\"` locally (pulls in `google-cloud-storage` and `requests`).\n",
"- An OETC account with valid credentials.\n",
"- The OETC authentication and orchestrator server URLs.\n",
"\n",
"Build an `OetcSettings`. Two construction styles:\n",
"\n",
"1. **Manually** — pass `email`, `password`, `name`, and the server URLs.\n",
"2. **`OetcSettings.from_env()`** — resolve everything from environment variables (`OETC_EMAIL`, `OETC_PASSWORD`, `OETC_NAME`, `OETC_AUTH_URL`, `OETC_ORCHESTRATOR_URL`). Recommended for CI/CD. Keyword arguments override the environment.\n",
"\n",
"linopy uploads the model to OETC, submits a compute job, polls until it finishes, and downloads the solution — all behind one call."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7",
"metadata": {},
"outputs": [],
"source": [
"from linopy.remote import OetcSettings\n",
"\n",
"# Option 1: pass credentials directly\n",
"oetc_settings = OetcSettings(\n",
" email=\"your-email@example.com\",\n",
" password=\"your-password\",\n",
" name=\"linopy-example-job\",\n",
" authentication_server_url=\"https://auth.oetcloud.com\",\n",
" orchestrator_server_url=\"https://orchestrator.oetcloud.com\",\n",
" cpu_cores=4,\n",
" disk_space_gb=20,\n",
")\n",
"\n",
"# Option 2: load from environment (with optional overrides)\n",
"oetc_settings = OetcSettings.from_env(cpu_cores=4, disk_space_gb=20)\n",
"\n",
"m.solve(\"gurobi\", remote=oetc_settings, TimeLimit=600, MIPGap=0.01)\n",
"\n",
"print(f\"Status: {m.status}\")\n",
"print(f\"Objective: {m.objective.value:.4f}\")\n",
"m.solution"
]
},
{
"cell_type": "markdown",
"id": "8",
"metadata": {},
"source": [
"## Advanced: drive the transport directly\n",
"\n",
"For finer control — inspecting the round-tripped solved model, splitting submit from collect for async workflows — use the `Oetc` or `SSH` class directly. `Model.solve(remote=...)` runs the same path internally and then writes the result back onto the local model in place.\n",
"\n",
"### SSH"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9",
"metadata": {},
"outputs": [],
"source": [
"from linopy.remote import SSH\n",
"\n",
"ssh = SSH(\n",
" settings=ssh_settings,\n",
" solver_name=\"gurobi\",\n",
" options={\"presolve\": \"on\"},\n",
")\n",
"result = ssh.solve(m)\n",
"m.assign_result(result)"
]
},
{
"cell_type": "markdown",
"id": "10",
"metadata": {},
"source": [
"### OETC\n",
"\n",
"`Oetc` exposes the three steps `Model.solve(remote=...)` does internally:\n",
"\n",
"1. `upload(model)` — serialize and push the netcdf to OETC.\n",
"2. `submit()` — submit the compute job; returns the job uuid.\n",
"3. `collect(model)` — wait for completion, download, build the `Result`.\n",
"\n",
"Splitting them lets you fire off a job, do other work, and come back to collect later — useful for long-running jobs or async-style workflows."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "11",
"metadata": {},
"outputs": [],
"source": [
"from linopy.remote import Oetc\n",
"\n",
"oetc = Oetc(\n",
" settings=oetc_settings,\n",
" solver_name=\"gurobi\",\n",
" options={\"TimeLimit\": 600, \"MIPGap\": 0.01},\n",
")\n",
"\n",
"oetc.upload(m)\n",
"job_uuid = oetc.submit()\n",
"print(f\"Submitted job {job_uuid} — do other work here ...\")\n",
"\n",
"# Later (or in another process holding `oetc`):\n",
"result = oetc.collect(m)\n",
"m.assign_result(result)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python"
},
"nbsphinx": {
"execute": "never"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
Loading
Loading