diff --git a/.github/workflows/test-notebooks.yml b/.github/workflows/test-notebooks.yml new file mode 100644 index 00000000..dfe025d2 --- /dev/null +++ b/.github/workflows/test-notebooks.yml @@ -0,0 +1,58 @@ +name: Test Notebooks + +on: + push: + branches: [ master ] + pull_request: + branches: [ '*' ] + schedule: + - cron: "0 5 * * TUE" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + notebooks: + name: Test documentation notebooks + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install package and dependencies + run: | + python -m pip install uv + uv pip install --system -e ".[docs]" + + - name: Execute notebooks + run: | + EXIT_CODE=0 + for notebook in examples/*.ipynb; do + name=$(basename "$notebook") + + # 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)" + continue + ;; + esac + + echo "::group::Running $name" + if jupyter nbconvert --to notebook --execute --ExecutePreprocessor.timeout=600 "$notebook"; then + echo "✓ $name passed" + else + echo "::error::✗ $name failed" + EXIT_CODE=1 + fi + echo "::endgroup::" + done + exit $EXIT_CODE diff --git a/doc/conf.py b/doc/conf.py index d7cce91b..5525d366 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -91,16 +91,13 @@ """ -nbsphinx_allow_errors = True +nbsphinx_allow_errors = False nbsphinx_execute = "auto" nbsphinx_execute_arguments = [ "--InlineBackend.figure_formats={'svg', 'pdf'}", "--InlineBackend.rc={'figure.dpi': 96}", ] -# Exclude notebooks that require credentials or special setup -nbsphinx_execute_never = ["**/solve-on-oetc*"] - # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 4646e87d..5c85000a 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -3,7 +3,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "# Piecewise Linear Constraints Tutorial\n\nThis notebook demonstrates linopy's piecewise linear (PWL) constraint formulations.\nEach example builds a separate dispatch model where a single power plant must meet\na time-varying demand.\n\n| Example | Plant | Limitation | Formulation |\n|---------|-------|------------|-------------|\n| 1 | Gas turbine (0–100 MW) | Convex heat rate | SOS2 |\n| 2 | Coal plant (0–150 MW) | Monotonic heat rate | Incremental |\n| 3 | Diesel generator (off or 50–80 MW) | Forbidden zone | Disjunctive |\n| 4 | Concave efficiency curve | Inequality bound | LP |\n| 5 | Gas unit with commitment | On/off + min load | Incremental + `active` |\n\n**Note:** The `piecewise(...)` expression can appear on either side of\nthe comparison operator (`==`, `<=`, `>=`). For example, both\n`linopy.piecewise(x, x_pts, y_pts) == y` and `y == linopy.piecewise(...)` work." + "source": "# Piecewise Linear Constraints Tutorial\n\nThis notebook demonstrates linopy's piecewise linear (PWL) constraint formulations.\nEach example builds a separate dispatch model where a single power plant must meet\na time-varying demand.\n\n| Example | Plant | Limitation | Formulation |\n|---------|-------|------------|-------------|\n| 1 | Gas turbine (0\u2013100 MW) | Convex heat rate | SOS2 |\n| 2 | Coal plant (0\u2013150 MW) | Monotonic heat rate | Incremental |\n| 3 | Diesel generator (off or 50\u201380 MW) | Forbidden zone | Disjunctive |\n| 4 | Concave efficiency curve | Inequality bound | LP |\n| 5 | Gas unit with commitment | On/off + min load | Incremental + `active` |\n\n**Note:** The `piecewise(...)` expression can appear on either side of\nthe comparison operator (`==`, `<=`, `>=`). For example, both\n`linopy.piecewise(x, x_pts, y_pts) == y` and `y == linopy.piecewise(...)` work." }, { "cell_type": "code", @@ -90,7 +90,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 1. SOS2 formulation — Gas turbine\n", + "## 1. SOS2 formulation \u2014 Gas turbine\n", "\n", "The gas turbine has a **convex** heat rate: efficient at moderate load,\n", "increasingly fuel-hungry at high output. We use the **SOS2** formulation\n", @@ -173,7 +173,7 @@ } }, "source": [ - "m1.solve()" + "m1.solve(reformulate_sos=\"auto\")" ], "outputs": [], "execution_count": null @@ -224,11 +224,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 2. Incremental formulation — Coal plant\n", + "## 2. Incremental formulation \u2014 Coal plant\n", "\n", "The coal plant has a **monotonically increasing** heat rate. Since all\n", "breakpoints are strictly monotonic, we can use the **incremental**\n", - "formulation — which uses fill-fraction variables with binary indicators." + "formulation \u2014 which uses fill-fraction variables with binary indicators." ] }, { @@ -306,7 +306,7 @@ } }, "source": [ - "m2.solve();" + "m2.solve(reformulate_sos=\"auto\");" ], "outputs": [], "execution_count": null @@ -357,10 +357,10 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 3. Disjunctive formulation — Diesel generator\n", + "## 3. Disjunctive formulation \u2014 Diesel generator\n", "\n", "The diesel generator has a **forbidden operating zone**: it must either\n", - "be off (0 MW) or run between 50–80 MW. Because of this gap, we use\n", + "be off (0 MW) or run between 50\u201380 MW. Because of this gap, we use\n", "**disjunctive** piecewise constraints via `linopy.segments()` and add a\n", "high-cost **backup** source to cover demand when the diesel is off or\n", "at its maximum.\n", @@ -446,7 +446,7 @@ } }, "source": [ - "m3.solve()" + "m3.solve(reformulate_sos=\"auto\")" ], "outputs": [], "execution_count": null @@ -476,11 +476,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 4. LP formulation — Concave efficiency bound\n", + "## 4. LP formulation \u2014 Concave efficiency bound\n", "\n", "When the piecewise function is **concave** and we use a `>=` constraint\n", "(i.e. `pw >= y`, meaning y is bounded above by pw), linopy can use a\n", - "pure **LP** formulation with tangent-line constraints — no SOS2 or\n", + "pure **LP** formulation with tangent-line constraints \u2014 no SOS2 or\n", "binary variables needed. This is the fastest to solve.\n", "\n", "For this formulation, the x-breakpoints must be in **strictly increasing**\n", @@ -514,7 +514,7 @@ "power = m4.add_variables(name=\"power\", lower=0, upper=120, coords=[time])\n", "fuel = m4.add_variables(name=\"fuel\", lower=0, coords=[time])\n", "\n", - "# pw >= fuel means fuel <= concave_function(power) → auto-selects LP method\n", + "# pw >= fuel means fuel <= concave_function(power) \u2192 auto-selects LP method\n", "m4.add_piecewise_constraints(\n", " linopy.piecewise(power, x_pts4, y_pts4) >= fuel,\n", " name=\"pwl\",\n", @@ -544,7 +544,7 @@ } }, "source": [ - "m4.solve()" + "m4.solve(reformulate_sos=\"auto\")" ], "outputs": [], "execution_count": null @@ -595,7 +595,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 5. Slopes mode — Building breakpoints from slopes\n", + "## 5. Slopes mode \u2014 Building breakpoints from slopes\n", "\n", "Sometimes you know the **slope** of each segment rather than the y-values\n", "at each breakpoint. The `breakpoints()` factory can compute y-values from\n", @@ -628,7 +628,7 @@ }, { "cell_type": "markdown", - "source": "## 6. Active parameter — Unit commitment with piecewise efficiency\n\nIn unit commitment problems, a binary variable $u_t$ controls whether a\nunit is **on** or **off**. When off, both power output and fuel consumption\nmust be zero. When on, the unit operates within its piecewise-linear\nefficiency curve between $P_{min}$ and $P_{max}$.\n\nThe `active` parameter on `piecewise()` handles this by gating the\ninternal PWL formulation with the commitment binary:\n\n- **Incremental:** delta bounds tighten from $\\delta_i \\leq 1$ to\n $\\delta_i \\leq u$, and base terms are multiplied by $u$\n- **SOS2:** convexity constraint becomes $\\sum \\lambda_i = u$\n- **Disjunctive:** segment selection becomes $\\sum z_k = u$\n\nThis is the only gating behavior expressible with pure linear constraints.\nSelectively *relaxing* the PWL (letting x, y float freely when off) would\nrequire big-M or indicator constraints.", + "source": "## 6. Active parameter \u2014 Unit commitment with piecewise efficiency\n\nIn unit commitment problems, a binary variable $u_t$ controls whether a\nunit is **on** or **off**. When off, both power output and fuel consumption\nmust be zero. When on, the unit operates within its piecewise-linear\nefficiency curve between $P_{min}$ and $P_{max}$.\n\nThe `active` parameter on `piecewise()` handles this by gating the\ninternal PWL formulation with the commitment binary:\n\n- **Incremental:** delta bounds tighten from $\\delta_i \\leq 1$ to\n $\\delta_i \\leq u$, and base terms are multiplied by $u$\n- **SOS2:** convexity constraint becomes $\\sum \\lambda_i = u$\n- **Disjunctive:** segment selection becomes $\\sum z_k = u$\n\nThis is the only gating behavior expressible with pure linear constraints.\nSelectively *relaxing* the PWL (letting x, y float freely when off) would\nrequire big-M or indicator constraints.", "metadata": {} }, { @@ -666,7 +666,7 @@ }, { "cell_type": "code", - "source": "m6.solve()", + "source": "m6.solve(reformulate_sos=\"auto\")", "metadata": { "ExecuteTime": { "end_time": "2026-03-09T10:17:28.878112Z", @@ -855,7 +855,7 @@ }, { "cell_type": "markdown", - "source": "At **t=1**, demand (15 MW) is below the minimum load (30 MW). The solver\nkeeps the unit off (`commit=0`), so `power=0` and `fuel=0` — the `active`\nparameter enforces this. Demand is met by the backup source.\n\nAt **t=2** and **t=3**, the unit commits and operates on the PWL curve.", + "source": "At **t=1**, demand (15 MW) is below the minimum load (30 MW). The solver\nkeeps the unit off (`commit=0`), so `power=0` and `fuel=0` \u2014 the `active`\nparameter enforces this. Demand is met by the backup source.\n\nAt **t=2** and **t=3**, the unit commits and operates on the PWL curve.", "metadata": {} } ], diff --git a/examples/solve-on-oetc.ipynb b/examples/solve-on-oetc.ipynb index 7459bdb9..975cd1fe 100644 --- a/examples/solve-on-oetc.ipynb +++ b/examples/solve-on-oetc.ipynb @@ -30,6 +30,13 @@ "All of these steps are handled automatically by linopy's `OetcHandler`." ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "> **Note:** This notebook requires Google Cloud credentials and access to the OETC platform. It is not executed during the documentation build, so no cell outputs are shown. To run it yourself, install the `linopy[oetc]` extra and configure your credentials." + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -356,6 +363,9 @@ "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.12.3" + }, + "nbsphinx": { + "execute": "never" } }, "nbformat": 4, diff --git a/examples/solve-on-remote.ipynb b/examples/solve-on-remote.ipynb index 16e01b41..4e2a1b13 100644 --- a/examples/solve-on-remote.ipynb +++ b/examples/solve-on-remote.ipynb @@ -4,12 +4,22 @@ "cell_type": "markdown", "id": "4db583af", "metadata": {}, - "source": ["# Remote Solving with SSH", "\n", + "source": [ + "# Remote Solving with SSH", + "\n", "This example demonstrates how linopy can solve optimization models on remote machines using SSH connections. This is one of two remote solving options available in linopy", "\n", "1. **SSH Remote Solving** (this example) - Connect to your own servers via SSH\\n2. **OETC Cloud Solving** - Use cloud-based optimization services (see `solve-on-oetc.ipynb`)", "\n\n", - "## SSH Remote Solving\\n\\nSSH remote solving is ideal when you have:\\n* Access to dedicated servers with optimization solvers installed\\n* Full control over the computing environment\\n* Existing infrastructure for optimization workloads\\n\\n## What you need for SSH remote solving:\\n* A running installation of paramiko on your local machine (`pip install paramiko`)\\n* A remote server with a working installation of linopy (e.g., in a conda environment)\\n* SSH access to that machine\\n\\n## How SSH Remote Solving Works\\n\\nThe workflow consists of the following steps, most of which linopy handles automatically:\\n\\n1. Define a model on the local machine\\n2. Save the model on the remote machine via SSH\\n3. Load, solve and write out the model on the remote machine\\n4. Copy the solved model back to the local machine\\n5. Load the solved model on the local machine\\n\\nThe model initialization happens locally, while the actual solving happens remotely.\""] + "## SSH Remote Solving\\n\\nSSH remote solving is ideal when you have:\\n* Access to dedicated servers with optimization solvers installed\\n* Full control over the computing environment\\n* Existing infrastructure for optimization workloads\\n\\n## What you need for SSH remote solving:\\n* A running installation of paramiko on your local machine (`pip install paramiko`)\\n* A remote server with a working installation of linopy (e.g., in a conda environment)\\n* SSH access to that machine\\n\\n## How SSH Remote Solving Works\\n\\nThe workflow consists of the following steps, most of which linopy handles automatically:\\n\\n1. Define a model on the local machine\\n2. Save the model on the remote machine via SSH\\n3. Load, solve and write out the model on the remote machine\\n4. Copy the solved model back to the local machine\\n5. Load the solved model on the local machine\\n\\nThe model initialization happens locally, while the actual solving happens remotely.\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "> **Note:** This notebook requires SSH access to a remote server with a solver installed. It is not executed during the documentation build, so no cell outputs are shown. To run it yourself, configure SSH access and install a solver on the remote machine." + ] }, { "cell_type": "markdown", @@ -311,7 +321,7 @@ "\n", ".xr-section-summary-in + label:before {\n", " display: inline-block;\n", - " content: '►';\n", + " content: '\u25ba';\n", " font-size: 11px;\n", " width: 15px;\n", " text-align: center;\n", @@ -322,7 +332,7 @@ "}\n", "\n", ".xr-section-summary-in:checked + label:before {\n", - " content: '▼';\n", + " content: '\u25bc';\n", "}\n", "\n", ".xr-section-summary-in:checked + label > span {\n", @@ -610,6 +620,9 @@ "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.11.3" + }, + "nbsphinx": { + "execute": "never" } }, "nbformat": 4, diff --git a/pyproject.toml b/pyproject.toml index 14a53a22..ad5390b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ docs = [ "nbsphinx-link==1.3.0", "docutils<0.21", "numpy<2", - "gurobipy==11.0.2", + "gurobipy>=13.0.0", "ipykernel==6.29.5", "matplotlib==3.9.1", "highspy>=1.7.1",