From 3d93e27a55b408d19fde808feb342ecd3e045887 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 08:36:16 +0200 Subject: [PATCH 01/30] refac: replace piecewise descriptor pattern with stateless construction layer Remove PiecewiseExpression, PiecewiseConstraintDescriptor, and the piecewise() function. Replace with an overloaded add_piecewise_constraints() that supports both a 2-variable positional API and an N-variable dict API for linking 3+ expressions through shared lambda weights. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/piecewise-linear-constraints.ipynb | 636 ++++++++-------- linopy/__init__.py | 3 +- linopy/expressions.py | 66 +- linopy/piecewise.py | 558 +++++++++----- linopy/types.py | 5 +- linopy/variables.py | 25 +- test/test_piecewise_constraints.py | 779 ++++++++++++-------- 7 files changed, 1163 insertions(+), 909 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 5c85000a..bddfe1c9 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -3,23 +3,25 @@ { "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\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." + "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| 6 | CHP plant (N-variable) | Joint power/fuel/heat | N-variable SOS2 |\n\n**API:** `m.add_piecewise_constraints(x, y, x_pts, y_pts, sign=\"==\")` for\ntwo-variable constraints, or `m.add_piecewise_constraints(exprs={...}, breakpoints=bp)`\nfor N-variable constraints." }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:27.800436Z", + "start_time": "2026-03-09T10:17:27.796927Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.167007Z", "iopub.status.busy": "2026-03-06T11:51:29.166576Z", "iopub.status.idle": "2026-03-06T11:51:29.185103Z", "shell.execute_reply": "2026-03-06T11:51:29.184712Z", "shell.execute_reply.started": "2026-03-06T11:51:29.166974Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:27.800436Z", - "start_time": "2026-03-09T10:17:27.796927Z" } }, + "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "import pandas as pd\n", @@ -82,15 +84,13 @@ " )\n", " ax2.legend()\n", " plt.tight_layout()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## 1. SOS2 formulation \u2014 Gas turbine\n", + "## 1. SOS2 formulation — 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", @@ -99,53 +99,58 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:27.808870Z", + "start_time": "2026-03-09T10:17:27.806626Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.185693Z", "iopub.status.busy": "2026-03-06T11:51:29.185601Z", "iopub.status.idle": "2026-03-06T11:51:29.199760Z", "shell.execute_reply": "2026-03-06T11:51:29.199416Z", "shell.execute_reply.started": "2026-03-06T11:51:29.185683Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:27.808870Z", - "start_time": "2026-03-09T10:17:27.806626Z" } }, + "outputs": [], "source": [ "x_pts1 = linopy.breakpoints([0, 30, 60, 100])\n", "y_pts1 = linopy.breakpoints([0, 36, 84, 170])\n", "print(\"x_pts:\", x_pts1.values)\n", "print(\"y_pts:\", y_pts1.values)" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:27.851223Z", + "start_time": "2026-03-09T10:17:27.811464Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.200170Z", "iopub.status.busy": "2026-03-06T11:51:29.200087Z", "iopub.status.idle": "2026-03-06T11:51:29.266847Z", "shell.execute_reply": "2026-03-06T11:51:29.266379Z", "shell.execute_reply.started": "2026-03-06T11:51:29.200161Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:27.851223Z", - "start_time": "2026-03-09T10:17:27.811464Z" } }, + "outputs": [], "source": [ "m1 = linopy.Model()\n", "\n", "power = m1.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", "fuel = m1.add_variables(name=\"fuel\", lower=0, coords=[time])\n", "\n", - "# piecewise(...) can be written on either side of the comparison\n", + "# 2-variable API: x, y, x_points, y_points\n", "# breakpoints are auto-broadcast to match the time dimension\n", "m1.add_piecewise_constraints(\n", - " linopy.piecewise(power, x_pts1, y_pts1) == fuel,\n", + " power,\n", + " fuel,\n", + " x_pts1,\n", + " y_pts1,\n", " name=\"pwl\",\n", " method=\"sos2\",\n", ")\n", @@ -153,123 +158,123 @@ "demand1 = xr.DataArray([50, 80, 30], coords=[time])\n", "m1.add_constraints(power >= demand1, name=\"demand\")\n", "m1.add_objective(fuel.sum())" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:27.899254Z", + "start_time": "2026-03-09T10:17:27.854515Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.267522Z", "iopub.status.busy": "2026-03-06T11:51:29.267433Z", "iopub.status.idle": "2026-03-06T11:51:29.326758Z", "shell.execute_reply": "2026-03-06T11:51:29.326518Z", "shell.execute_reply.started": "2026-03-06T11:51:29.267514Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:27.899254Z", - "start_time": "2026-03-09T10:17:27.854515Z" } }, + "outputs": [], "source": [ "m1.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:27.914316Z", + "start_time": "2026-03-09T10:17:27.909570Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.327139Z", "iopub.status.busy": "2026-03-06T11:51:29.327044Z", "iopub.status.idle": "2026-03-06T11:51:29.339334Z", "shell.execute_reply": "2026-03-06T11:51:29.338974Z", "shell.execute_reply.started": "2026-03-06T11:51:29.327130Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:27.914316Z", - "start_time": "2026-03-09T10:17:27.909570Z" } }, + "outputs": [], "source": [ "m1.solution[[\"power\", \"fuel\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:28.025921Z", + "start_time": "2026-03-09T10:17:27.922945Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.339689Z", "iopub.status.busy": "2026-03-06T11:51:29.339608Z", "iopub.status.idle": "2026-03-06T11:51:29.489677Z", "shell.execute_reply": "2026-03-06T11:51:29.489280Z", "shell.execute_reply.started": "2026-03-06T11:51:29.339680Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.025921Z", - "start_time": "2026-03-09T10:17:27.922945Z" } }, + "outputs": [], "source": [ "plot_pwl_results(m1, x_pts1, y_pts1, demand1, color=\"C0\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## 2. Incremental formulation \u2014 Coal plant\n", + "## 2. Incremental formulation — 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 \u2014 which uses fill-fraction variables with binary indicators." + "formulation — which uses fill-fraction variables with binary indicators." ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:28.039245Z", + "start_time": "2026-03-09T10:17:28.035712Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.490092Z", "iopub.status.busy": "2026-03-06T11:51:29.490011Z", "iopub.status.idle": "2026-03-06T11:51:29.500894Z", "shell.execute_reply": "2026-03-06T11:51:29.500558Z", "shell.execute_reply.started": "2026-03-06T11:51:29.490084Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.039245Z", - "start_time": "2026-03-09T10:17:28.035712Z" } }, + "outputs": [], "source": [ "x_pts2 = linopy.breakpoints([0, 50, 100, 150])\n", "y_pts2 = linopy.breakpoints([0, 55, 130, 225])\n", "print(\"x_pts:\", x_pts2.values)\n", "print(\"y_pts:\", y_pts2.values)" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:28.121499Z", + "start_time": "2026-03-09T10:17:28.052395Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.501317Z", "iopub.status.busy": "2026-03-06T11:51:29.501216Z", "iopub.status.idle": "2026-03-06T11:51:29.604024Z", "shell.execute_reply": "2026-03-06T11:51:29.603543Z", "shell.execute_reply.started": "2026-03-06T11:51:29.501307Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.121499Z", - "start_time": "2026-03-09T10:17:28.052395Z" } }, + "outputs": [], "source": [ "m2 = linopy.Model()\n", "\n", @@ -278,7 +283,10 @@ "\n", "# breakpoints are auto-broadcast to match the time dimension\n", "m2.add_piecewise_constraints(\n", - " linopy.piecewise(power, x_pts2, y_pts2) == fuel,\n", + " power,\n", + " fuel,\n", + " x_pts2,\n", + " y_pts2,\n", " name=\"pwl\",\n", " method=\"incremental\",\n", ")\n", @@ -286,81 +294,79 @@ "demand2 = xr.DataArray([80, 120, 50], coords=[time])\n", "m2.add_constraints(power >= demand2, name=\"demand\")\n", "m2.add_objective(fuel.sum())" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:28.174903Z", + "start_time": "2026-03-09T10:17:28.124418Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.604434Z", "iopub.status.busy": "2026-03-06T11:51:29.604359Z", "iopub.status.idle": "2026-03-06T11:51:29.680947Z", "shell.execute_reply": "2026-03-06T11:51:29.680667Z", "shell.execute_reply.started": "2026-03-06T11:51:29.604427Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.174903Z", - "start_time": "2026-03-09T10:17:28.124418Z" } }, + "outputs": [], "source": [ "m2.solve(reformulate_sos=\"auto\");" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:28.182912Z", + "start_time": "2026-03-09T10:17:28.178226Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.681833Z", "iopub.status.busy": "2026-03-06T11:51:29.681725Z", "iopub.status.idle": "2026-03-06T11:51:29.698558Z", "shell.execute_reply": "2026-03-06T11:51:29.698011Z", "shell.execute_reply.started": "2026-03-06T11:51:29.681822Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.182912Z", - "start_time": "2026-03-09T10:17:28.178226Z" } }, + "outputs": [], "source": [ "m2.solution[[\"power\", \"fuel\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:28.285938Z", + "start_time": "2026-03-09T10:17:28.191498Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.699350Z", "iopub.status.busy": "2026-03-06T11:51:29.699116Z", "iopub.status.idle": "2026-03-06T11:51:29.852000Z", "shell.execute_reply": "2026-03-06T11:51:29.851741Z", "shell.execute_reply.started": "2026-03-06T11:51:29.699334Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.285938Z", - "start_time": "2026-03-09T10:17:28.191498Z" } }, + "outputs": [], "source": [ "plot_pwl_results(m2, x_pts2, y_pts2, demand2, color=\"C1\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## 3. Disjunctive formulation \u2014 Diesel generator\n", + "## 3. Disjunctive formulation — Diesel generator\n", "\n", "The diesel generator has a **forbidden operating zone**: it must either\n", - "be off (0 MW) or run between 50\u201380 MW. Because of this gap, we use\n", + "be off (0 MW) or run between 50–80 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", @@ -371,19 +377,21 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:28.301657Z", + "start_time": "2026-03-09T10:17:28.294924Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.852397Z", "iopub.status.busy": "2026-03-06T11:51:29.852305Z", "iopub.status.idle": "2026-03-06T11:51:29.866500Z", "shell.execute_reply": "2026-03-06T11:51:29.866141Z", "shell.execute_reply.started": "2026-03-06T11:51:29.852387Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.301657Z", - "start_time": "2026-03-09T10:17:28.294924Z" } }, + "outputs": [], "source": [ "# x-breakpoints define where each segment lives on the power axis\n", "# y-breakpoints define the corresponding cost values\n", @@ -391,25 +399,25 @@ "y_seg = linopy.segments([(0, 0), (125, 200)])\n", "print(\"x segments:\\n\", x_seg.to_pandas())\n", "print(\"y segments:\\n\", y_seg.to_pandas())" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:28.381180Z", + "start_time": "2026-03-09T10:17:28.308026Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.866940Z", "iopub.status.busy": "2026-03-06T11:51:29.866839Z", "iopub.status.idle": "2026-03-06T11:51:29.955272Z", "shell.execute_reply": "2026-03-06T11:51:29.954810Z", "shell.execute_reply.started": "2026-03-06T11:51:29.866931Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.381180Z", - "start_time": "2026-03-09T10:17:28.308026Z" } }, + "outputs": [], "source": [ "m3 = linopy.Model()\n", "\n", @@ -419,68 +427,69 @@ "\n", "# breakpoints are auto-broadcast to match the time dimension\n", "m3.add_piecewise_constraints(\n", - " linopy.piecewise(power, x_seg, y_seg) == cost,\n", + " power,\n", + " cost,\n", + " x_seg,\n", + " y_seg,\n", " name=\"pwl\",\n", ")\n", "\n", "demand3 = xr.DataArray([10, 70, 90], coords=[time])\n", "m3.add_constraints(power + backup >= demand3, name=\"demand\")\n", "m3.add_objective((cost + 10 * backup).sum())" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:28.437326Z", + "start_time": "2026-03-09T10:17:28.384629Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.955750Z", "iopub.status.busy": "2026-03-06T11:51:29.955667Z", "iopub.status.idle": "2026-03-06T11:51:30.027311Z", "shell.execute_reply": "2026-03-06T11:51:30.026945Z", "shell.execute_reply.started": "2026-03-06T11:51:29.955741Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.437326Z", - "start_time": "2026-03-09T10:17:28.384629Z" } }, + "outputs": [], "source": [ "m3.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:28.449248Z", + "start_time": "2026-03-09T10:17:28.444065Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.028114Z", "iopub.status.busy": "2026-03-06T11:51:30.027864Z", "iopub.status.idle": "2026-03-06T11:51:30.043138Z", "shell.execute_reply": "2026-03-06T11:51:30.042813Z", "shell.execute_reply.started": "2026-03-06T11:51:30.028095Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.449248Z", - "start_time": "2026-03-09T10:17:28.444065Z" } }, + "outputs": [], "source": [ "m3.solution[[\"power\", \"cost\", \"backup\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## 4. LP formulation \u2014 Concave efficiency bound\n", + "## 4. LP formulation — 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 \u2014 no SOS2 or\n", + "pure **LP** formulation with tangent-line constraints — 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", @@ -491,19 +500,21 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:28.503165Z", + "start_time": "2026-03-09T10:17:28.458328Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.043492Z", "iopub.status.busy": "2026-03-06T11:51:30.043410Z", "iopub.status.idle": "2026-03-06T11:51:30.113382Z", "shell.execute_reply": "2026-03-06T11:51:30.112320Z", "shell.execute_reply.started": "2026-03-06T11:51:30.043484Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.503165Z", - "start_time": "2026-03-09T10:17:28.458328Z" } }, + "outputs": [], "source": [ "x_pts4 = linopy.breakpoints([0, 40, 80, 120])\n", "# Concave curve: decreasing marginal fuel per MW\n", @@ -514,9 +525,13 @@ "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) \u2192 auto-selects LP method\n", + "# fuel <= concave_function(power): sign=\"<=\" auto-selects LP method\n", "m4.add_piecewise_constraints(\n", - " linopy.piecewise(power, x_pts4, y_pts4) >= fuel,\n", + " power,\n", + " fuel,\n", + " x_pts4,\n", + " y_pts4,\n", + " sign=\"<=\",\n", " name=\"pwl\",\n", ")\n", "\n", @@ -524,78 +539,76 @@ "m4.add_constraints(power == demand4, name=\"demand\")\n", "# Maximize fuel (to push against the upper bound)\n", "m4.add_objective(-fuel.sum())" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:28.554560Z", + "start_time": "2026-03-09T10:17:28.520243Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.113818Z", "iopub.status.busy": "2026-03-06T11:51:30.113727Z", "iopub.status.idle": "2026-03-06T11:51:30.171329Z", "shell.execute_reply": "2026-03-06T11:51:30.170942Z", "shell.execute_reply.started": "2026-03-06T11:51:30.113810Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.554560Z", - "start_time": "2026-03-09T10:17:28.520243Z" } }, + "outputs": [], "source": [ "m4.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:28.563539Z", + "start_time": "2026-03-09T10:17:28.559654Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.172009Z", "iopub.status.busy": "2026-03-06T11:51:30.171791Z", "iopub.status.idle": "2026-03-06T11:51:30.191956Z", "shell.execute_reply": "2026-03-06T11:51:30.191556Z", "shell.execute_reply.started": "2026-03-06T11:51:30.171993Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.563539Z", - "start_time": "2026-03-09T10:17:28.559654Z" } }, + "outputs": [], "source": [ "m4.solution[[\"power\", \"fuel\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:28.665419Z", + "start_time": "2026-03-09T10:17:28.575163Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.192604Z", "iopub.status.busy": "2026-03-06T11:51:30.192376Z", "iopub.status.idle": "2026-03-06T11:51:30.345074Z", "shell.execute_reply": "2026-03-06T11:51:30.344642Z", "shell.execute_reply.started": "2026-03-06T11:51:30.192590Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.665419Z", - "start_time": "2026-03-09T10:17:28.575163Z" } }, + "outputs": [], "source": [ "plot_pwl_results(m4, x_pts4, y_pts4, demand4, color=\"C4\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## 5. Slopes mode \u2014 Building breakpoints from slopes\n", + "## 5. Slopes mode — 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", @@ -604,57 +617,58 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-03-09T10:17:28.673673Z", + "start_time": "2026-03-09T10:17:28.668792Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.345523Z", "iopub.status.busy": "2026-03-06T11:51:30.345404Z", "iopub.status.idle": "2026-03-06T11:51:30.357312Z", "shell.execute_reply": "2026-03-06T11:51:30.356954Z", "shell.execute_reply.started": "2026-03-06T11:51:30.345513Z" - }, - "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.673673Z", - "start_time": "2026-03-09T10:17:28.668792Z" } }, + "outputs": [], "source": [ "# Marginal costs: $1.1/MW for 0-50, $1.5/MW for 50-100, $1.9/MW for 100-150\n", "x_pts5 = linopy.breakpoints([0, 50, 100, 150])\n", "y_pts5 = linopy.breakpoints(slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0)\n", "print(\"y breakpoints from slopes:\", y_pts5.values)" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", - "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": {} + "metadata": {}, + "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` keyword on `add_piecewise_constraints()` handles this by\ngating the internal 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." }, { "cell_type": "code", - "source": "# Unit parameters: operates between 30-100 MW when on\np_min, p_max = 30, 100\nfuel_min, fuel_max = 40, 170\nstartup_cost = 50\n\nx_pts6 = linopy.breakpoints([p_min, 60, p_max])\ny_pts6 = linopy.breakpoints([fuel_min, 90, fuel_max])\nprint(\"Power breakpoints:\", x_pts6.values)\nprint(\"Fuel breakpoints: \", y_pts6.values)", + "execution_count": null, "metadata": { "ExecuteTime": { "end_time": "2026-03-09T10:17:28.685034Z", "start_time": "2026-03-09T10:17:28.681601Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Power breakpoints: [ 30. 60. 100.]\n", - "Fuel breakpoints: [ 40. 90. 170.]\n" - ] - } - ], - "execution_count": null + "outputs": [], + "source": [ + "# Unit parameters: operates between 30-100 MW when on\n", + "p_min, p_max = 30, 100\n", + "fuel_min, fuel_max = 40, 170\n", + "startup_cost = 50\n", + "\n", + "x_pts6 = linopy.breakpoints([p_min, 60, p_max])\n", + "y_pts6 = linopy.breakpoints([fuel_min, 90, fuel_max])\n", + "print(\"Power breakpoints:\", x_pts6.values)\n", + "print(\"Fuel breakpoints: \", y_pts6.values)" + ] }, { "cell_type": "code", - "source": "m6 = linopy.Model()\n\npower = m6.add_variables(name=\"power\", lower=0, upper=p_max, coords=[time])\nfuel = m6.add_variables(name=\"fuel\", lower=0, coords=[time])\ncommit = m6.add_variables(name=\"commit\", binary=True, coords=[time])\n\n# The active parameter gates the PWL with the commitment binary:\n# - commit=1: power in [30, 100], fuel = f(power)\n# - commit=0: power = 0, fuel = 0\nm6.add_piecewise_constraints(\n linopy.piecewise(power, x_pts6, y_pts6, active=commit) == fuel,\n name=\"pwl\",\n method=\"incremental\",\n)\n\n# Demand: low at t=1 (cheaper to stay off), high at t=2,3\ndemand6 = xr.DataArray([15, 70, 50], coords=[time])\nbackup = m6.add_variables(name=\"backup\", lower=0, coords=[time])\nm6.add_constraints(power + backup >= demand6, name=\"demand\")\n\n# Objective: fuel + startup cost + backup at $5/MW (cheap enough that\n# staying off at low demand beats committing at minimum load)\nm6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())", + "execution_count": null, "metadata": { "ExecuteTime": { "end_time": "2026-03-09T10:17:28.787328Z", @@ -662,201 +676,153 @@ } }, "outputs": [], - "execution_count": null + "source": [ + "m6 = linopy.Model()\n", + "\n", + "power = m6.add_variables(name=\"power\", lower=0, upper=p_max, coords=[time])\n", + "fuel = m6.add_variables(name=\"fuel\", lower=0, coords=[time])\n", + "commit = m6.add_variables(name=\"commit\", binary=True, coords=[time])\n", + "\n", + "# The active parameter gates the PWL with the commitment binary:\n", + "# - commit=1: power in [30, 100], fuel = f(power)\n", + "# - commit=0: power = 0, fuel = 0\n", + "m6.add_piecewise_constraints(\n", + " power,\n", + " fuel,\n", + " x_pts6,\n", + " y_pts6,\n", + " active=commit,\n", + " name=\"pwl\",\n", + " method=\"incremental\",\n", + ")\n", + "\n", + "# Demand: low at t=1 (cheaper to stay off), high at t=2,3\n", + "demand6 = xr.DataArray([15, 70, 50], coords=[time])\n", + "backup = m6.add_variables(name=\"backup\", lower=0, coords=[time])\n", + "m6.add_constraints(power + backup >= demand6, name=\"demand\")\n", + "\n", + "# Objective: fuel + startup cost + backup at $5/MW (cheap enough that\n", + "# staying off at low demand beats committing at minimum load)\n", + "m6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())" + ] }, { "cell_type": "code", - "source": "m6.solve(reformulate_sos=\"auto\")", + "execution_count": null, "metadata": { "ExecuteTime": { "end_time": "2026-03-09T10:17:28.878112Z", "start_time": "2026-03-09T10:17:28.791383Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set parameter Username\n", - "Academic license - for non-commercial use only - expires 2026-12-18\n", - "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-fm9ucuy2.lp\n", - "Reading time = 0.00 seconds\n", - "obj: 27 rows, 24 columns, 66 nonzeros\n", - "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n", - "\n", - "CPU model: Apple M3\n", - "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n", - "\n", - "Optimize a model with 27 rows, 24 columns and 66 nonzeros (Min)\n", - "Model fingerprint: 0x4b0d5f70\n", - "Model has 9 linear objective coefficients\n", - "Variable types: 15 continuous, 9 integer (9 binary)\n", - "Coefficient statistics:\n", - " Matrix range [1e+00, 8e+01]\n", - " Objective range [1e+00, 5e+01]\n", - " Bounds range [1e+00, 1e+02]\n", - " RHS range [2e+01, 7e+01]\n", - "\n", - "Found heuristic solution: objective 675.0000000\n", - "Presolve removed 24 rows and 19 columns\n", - "Presolve time: 0.00s\n", - "Presolved: 3 rows, 5 columns, 10 nonzeros\n", - "Found heuristic solution: objective 485.0000000\n", - "Variable types: 3 continuous, 2 integer (2 binary)\n", - "\n", - "Root relaxation: objective 3.516667e+02, 3 iterations, 0.00 seconds (0.00 work units)\n", - "\n", - " Nodes | Current Node | Objective Bounds | Work\n", - " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", - "\n", - " 0 0 351.66667 0 1 485.00000 351.66667 27.5% - 0s\n", - "* 0 0 0 358.3333333 358.33333 0.00% - 0s\n", - "\n", - "Explored 1 nodes (5 simplex iterations) in 0.01 seconds (0.00 work units)\n", - "Thread count was 8 (of 8 available processors)\n", - "\n", - "Solution count 3: 358.333 485 675 \n", - "\n", - "Optimal solution found (tolerance 1.00e-04)\n", - "Best objective 3.583333333333e+02, best bound 3.583333333333e+02, gap 0.0000%\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Dual values of MILP couldn't be parsed\n" - ] - }, - { - "data": { - "text/plain": [ - "('ok', 'optimal')" - ] - }, - "execution_count": 47, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": null + "outputs": [], + "source": [ + "m6.solve(reformulate_sos=\"auto\")" + ] }, { "cell_type": "code", - "source": "m6.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()", + "execution_count": null, "metadata": { "ExecuteTime": { "end_time": "2026-03-09T10:17:29.079925Z", "start_time": "2026-03-09T10:17:29.069821Z" } }, - "outputs": [ - { - "data": { - "text/plain": [ - " commit power fuel backup\n", - "time \n", - "1 0.0 0.0 0.000000 15.0\n", - "2 1.0 70.0 110.000000 0.0\n", - "3 1.0 50.0 73.333333 0.0" - ], - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
commitpowerfuelbackup
time
10.00.00.00000015.0
21.070.0110.0000000.0
31.050.073.3333330.0
\n", - "
" - ] - }, - "execution_count": 48, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": null + "outputs": [], + "source": [ + "m6.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()" + ] }, { "cell_type": "code", - "source": "plot_pwl_results(m6, x_pts6, y_pts6, demand6, color=\"C2\")", + "execution_count": null, "metadata": { "ExecuteTime": { "end_time": "2026-03-09T10:17:29.226034Z", "start_time": "2026-03-09T10:17:29.097467Z" } }, - "outputs": [ - { - "data": { - "text/plain": [ - "
" - ], - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAFUCAYAAAA57l+/AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/TGe4hAAAACXBIWXMAAA9hAAAPYQGoP6dpAABq3ElEQVR4nO3dB3hU1fbw4ZVeKKETeu9SpDeRJoiCIlxRBKliowiIUqQLBlABQYpYKCqCKKCgYqFKbyId6b1Jh0DqfM/afjP/mZBAEjKZZOb33mducs6cmZycieyz9l57bS+LxWIRAAAAAACQ4rxT/i0BAAAAAABBNwAAAAAATsRINwAAAAAATkLQDQAAAACAkxB0AwAAAADgJATdAAAAAAA4CUE3AAAAAABOQtANAAAAAICTEHQDAAAAAOAkBN0AAABAGjJ8+HDx8vKS9E5/hx49erj6NACXI+gGnGTWrFmmsdm6dWu8z9evX18eeughp17/n3/+2TTcqWnq1KnmdwcAAI73BNZHYGCg5M2bV5o2bSqTJk2SGzdupMlL5Yr7CMAdEXQDbkwbyxEjRqTqzyToBgAgfiNHjpQvv/xSpk2bJj179jT7evfuLeXLl5edO3fajhs8eLDcvn3bI+8jAHfk6+oTAJB2WSwWuXPnjgQFBYm709/T399fvL3piwQAOEezZs2katWqtu2BAwfKihUrpHnz5vLUU0/Jvn37TJvr6+trHgDcA3eXQBrz1VdfSZUqVUyjmy1bNnn++efl5MmTDsf8+eef8uyzz0rBggUlICBAChQoIH369HHoFe/UqZNMmTLFfG+f0nYvhQsXNg3/r7/+am4K9Bw++eQT89zMmTOlYcOGkitXLvMzy5Yta3rq475+z549snr1atvP0zR6q6tXr5oefT1ffY/ixYvL2LFjJTY2NlHX5pdffpFHH31UMmXKJJkzZ5Zq1arJ3LlzHX6+/t5x6TnYn8eqVavMuc2bN8+MJuTLl0+Cg4Nl+/btZv/s2bPveg+9Jvrc0qVLbftOnz4tXbp0kdy5c5vfp1y5cvLFF18k6ncBAEBp2zpkyBA5fvy4uQdIaE7377//LnXr1pUsWbJIxowZpVSpUjJo0KC72rb58+eb/aGhoZIhQwYTzDvjPkLb7o8++siM0mu6fM6cOeXxxx+Pd1rd4sWLzZQ6a1u5bNkyPnx4FLrQACe7du2a/Pvvv3ftj4qKumvf6NGjTcPbpk0beemll+TixYsyefJkqVevnvz111+moVULFiyQ8PBwee211yR79uyyefNmc9ypU6fMc+qVV16RM2fOmEZaU9kS68CBA9K2bVvz+m7duplGXWmArQ2lNt7a+75kyRJ5/fXXTaPbvXt3c8zEiRNNupzeDLzzzjtmnwakSs9XA2YNVPW9taFfv3696eU/e/asee395sNpgKvnoK/Ra6HXRBvuF154QZLj3XffNaPb/fr1k4iICNORULRoUfn222+lY8eODsfqTUzWrFnN/Dt1/vx5qVmzpq1IjN5saKdA165d5fr166ZzAQCAxHjxxRdNoPzbb7+Ztjcu7dDWTvEKFSqYFHUNXg8dOiTr1q2L915C26b+/fvLhQsXTPvauHFj2bFjhy1zLSXuI7S907ZZR+/1niU6OtoE8xs3bnQYzV+7dq0sXLjQ3DNop7nOYW/durWcOHHC/GzAI1gAOMXMmTMt+p/YvR7lypWzHX/s2DGLj4+PZfTo0Q7vs2vXLouvr6/D/vDw8Lt+XlhYmMXLy8ty/Phx277u3bubn5NYhQoVMscvW7bsrufi+5lNmza1FC1a1GGf/k6PPvroXce+++67lgwZMlj++ecfh/0DBgwwv/eJEycSPK+rV69aMmXKZKlRo4bl9u3bDs/FxsY6nH/Hjh3ver2ej/05rVy50vyeeu5xf6+BAwda/Pz8LJcvX7bti4iIsGTJksXSpUsX276uXbta8uTJY/n3338dXv/8889bQkJC4r1eAADPvifYsmVLgsdo2/Hwww+b74cNG+bQfk+YMMFsX7x4McHXW9u2fPnyWa5fv27b/+2335r9H330UYrdR6xYscLs79Wr113P2bfLeoy/v7/l0KFDtn1///232T958uQEfxfA3ZBeDjiZpmZpL3Hch/ZW29NeYB011lFuHRm3PjQ9rESJErJy5UrbsfZzrG/dumWOq127tpmDraO/D6JIkSK20Vx79j/TOnqvI9dHjhwx2/ejPeePPPKIGS22//209z0mJkbWrFmT4Gv1emll1wEDBpgUNnsPsqSKjmbHna/+3HPPmSwE/TysdORBU+P1OaXX+fvvv5cWLVqY7+1/H712ej00VR0AgMTSLLGEqphbM91++OGH+07J6tChgxlRtvrf//4nefLkMUXRUuo+QttAbX+H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" - }, - "metadata": {}, - "output_type": "display_data", - "jetTransient": { - "display_id": null - } - } - ], - "execution_count": null + "outputs": [], + "source": [ + "plot_pwl_results(m6, x_pts6, y_pts6, demand6, color=\"C2\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "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." }, { "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` \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": {} + "metadata": {}, + "source": "## 7. N-variable formulation -- CHP plant\n\nWhen multiple outputs are linked through shared operating points (e.g., a\ncombined heat and power plant where power, fuel, and heat are all functions\nof a single loading parameter), use the **N-variable** API.\n\nInstead of separate x/y breakpoints, you pass a dictionary of expressions\nand a single breakpoint DataArray whose coordinates match the dictionary keys." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from linopy.constants import BREAKPOINT_DIM\n", + "\n", + "# CHP operating points: as load increases, power, fuel, and heat all change\n", + "# breakpoints array has a \"var\" dimension matching the expression dict keys\n", + "bp_chp = xr.DataArray(\n", + " [\n", + " [0.0, 30.0, 60.0, 100.0], # power [MW]\n", + " [0.0, 40.0, 85.0, 160.0], # fuel [MMBTU/h]\n", + " [0.0, 25.0, 55.0, 95.0],\n", + " ], # heat [MW_th]\n", + " dims=[\"var\", BREAKPOINT_DIM],\n", + " coords={\"var\": [\"power\", \"fuel\", \"heat\"], BREAKPOINT_DIM: [0, 1, 2, 3]},\n", + ")\n", + "print(\"CHP breakpoints:\")\n", + "print(bp_chp.to_pandas())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m7 = linopy.Model()\n", + "\n", + "power = m7.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", + "fuel = m7.add_variables(name=\"fuel\", lower=0, coords=[time])\n", + "heat = m7.add_variables(name=\"heat\", lower=0, coords=[time])\n", + "\n", + "# N-variable API: link power, fuel, and heat through shared breakpoints\n", + "m7.add_piecewise_constraints(\n", + " exprs={\"power\": power, \"fuel\": fuel, \"heat\": heat},\n", + " breakpoints=bp_chp,\n", + " name=\"chp\",\n", + " method=\"sos2\",\n", + ")\n", + "\n", + "demand7 = xr.DataArray([50, 80, 30], coords=[time])\n", + "m7.add_constraints(power >= demand7, name=\"elec_demand\")\n", + "m7.add_objective(fuel.sum())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m7.solve(reformulate_sos=\"auto\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m7.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas()" + ] } ], "metadata": { diff --git a/linopy/__init__.py b/linopy/__init__.py index b1dc33b9..498c9e12 100644 --- a/linopy/__init__.py +++ b/linopy/__init__.py @@ -20,7 +20,7 @@ from linopy.io import read_netcdf from linopy.model import Model, Variable, Variables, available_solvers from linopy.objective import Objective -from linopy.piecewise import breakpoints, piecewise, segments, slopes_to_points +from linopy.piecewise import breakpoints, segments, slopes_to_points from linopy.remote import RemoteHandler try: @@ -44,7 +44,6 @@ "Variables", "available_solvers", "breakpoints", - "piecewise", "segments", "slopes_to_points", "align", diff --git a/linopy/expressions.py b/linopy/expressions.py index ca491c3e..0031944d 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -92,33 +92,12 @@ if TYPE_CHECKING: from linopy.constraints import AnonymousScalarConstraint, Constraint from linopy.model import Model - from linopy.piecewise import PiecewiseConstraintDescriptor, PiecewiseExpression from linopy.variables import ScalarVariable, Variable FILL_VALUE = {"vars": -1, "coeffs": np.nan, "const": np.nan} -def _to_piecewise_constraint_descriptor( - lhs: Any, rhs: Any, operator: str -) -> PiecewiseConstraintDescriptor | None: - """Build a piecewise descriptor for reversed RHS syntax if applicable.""" - from linopy.piecewise import PiecewiseExpression - - if not isinstance(rhs, PiecewiseExpression): - return None - - if operator == "<=": - return rhs.__ge__(lhs) - if operator == ">=": - return rhs.__le__(lhs) - if operator == "==": - return rhs.__eq__(lhs) - - msg = f"Unsupported operator '{operator}' for piecewise dispatch." - raise ValueError(msg) - - def exprwrap( method: Callable, *default_args: Any, **new_default_kwargs: Any ) -> Callable: @@ -669,40 +648,13 @@ def __div__(self: GenericExpression, other: SideLike) -> GenericExpression: def __truediv__(self: GenericExpression, other: SideLike) -> GenericExpression: return self.__div__(other) - @overload - def __le__(self, rhs: PiecewiseExpression) -> PiecewiseConstraintDescriptor: ... - - @overload - def __le__(self, rhs: SideLike) -> Constraint: ... - - def __le__(self, rhs: SideLike) -> Constraint | PiecewiseConstraintDescriptor: - descriptor = _to_piecewise_constraint_descriptor(self, rhs, "<=") - if descriptor is not None: - return descriptor + def __le__(self, rhs: SideLike) -> Constraint: return self.to_constraint(LESS_EQUAL, rhs) - @overload - def __ge__(self, rhs: PiecewiseExpression) -> PiecewiseConstraintDescriptor: ... - - @overload - def __ge__(self, rhs: SideLike) -> Constraint: ... - - def __ge__(self, rhs: SideLike) -> Constraint | PiecewiseConstraintDescriptor: - descriptor = _to_piecewise_constraint_descriptor(self, rhs, ">=") - if descriptor is not None: - return descriptor + def __ge__(self, rhs: SideLike) -> Constraint: return self.to_constraint(GREATER_EQUAL, rhs) - @overload # type: ignore[override] - def __eq__(self, rhs: PiecewiseExpression) -> PiecewiseConstraintDescriptor: ... - - @overload - def __eq__(self, rhs: SideLike) -> Constraint: ... - - def __eq__(self, rhs: SideLike) -> Constraint | PiecewiseConstraintDescriptor: - descriptor = _to_piecewise_constraint_descriptor(self, rhs, "==") - if descriptor is not None: - return descriptor + def __eq__(self, rhs: SideLike) -> Constraint: return self.to_constraint(EQUAL, rhs) def __gt__(self, other: Any) -> NotImplementedType: @@ -2564,10 +2516,6 @@ def __truediv__(self, other: float | int) -> ScalarLinearExpression: return self.__div__(other) def __le__(self, other: int | float) -> AnonymousScalarConstraint: - descriptor = _to_piecewise_constraint_descriptor(self, other, "<=") - if descriptor is not None: - return descriptor # type: ignore[return-value] - if not isinstance(other, int | float | np.number): raise TypeError( f"unsupported operand type(s) for <=: {type(self)} and {type(other)}" @@ -2576,10 +2524,6 @@ def __le__(self, other: int | float) -> AnonymousScalarConstraint: return constraints.AnonymousScalarConstraint(self, LESS_EQUAL, other) def __ge__(self, other: int | float) -> AnonymousScalarConstraint: - descriptor = _to_piecewise_constraint_descriptor(self, other, ">=") - if descriptor is not None: - return descriptor # type: ignore[return-value] - if not isinstance(other, int | float | np.number): raise TypeError( f"unsupported operand type(s) for >=: {type(self)} and {type(other)}" @@ -2590,10 +2534,6 @@ def __ge__(self, other: int | float) -> AnonymousScalarConstraint: def __eq__( # type: ignore[override] self, other: int | float ) -> AnonymousScalarConstraint: - descriptor = _to_piecewise_constraint_descriptor(self, other, "==") - if descriptor is not None: - return descriptor # type: ignore[return-value] - if not isinstance(other, int | float | np.number): raise TypeError( f"unsupported operand type(s) for ==: {type(self)} and {type(other)}" diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 78f7be65..d0045f36 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -7,10 +7,9 @@ from __future__ import annotations -from collections.abc import Sequence -from dataclasses import dataclass +from collections.abc import Mapping, Sequence from numbers import Real -from typing import TYPE_CHECKING, Literal, TypeAlias +from typing import TYPE_CHECKING, Literal, TypeAlias, overload import numpy as np import pandas as pd @@ -363,94 +362,28 @@ def segments( return _coerce_segments(values, dim) -# --------------------------------------------------------------------------- -# Piecewise expression and descriptor types -# --------------------------------------------------------------------------- - - -class PiecewiseExpression: - """ - Lazy descriptor representing a piecewise linear function of an expression. - - Created by :func:`piecewise`. Supports comparison operators so that - ``piecewise(x, ...) >= y`` produces a - :class:`PiecewiseConstraintDescriptor`. - """ - - __slots__ = ("active", "disjunctive", "expr", "x_points", "y_points") - - def __init__( - self, - expr: LinExprLike, - x_points: DataArray, - y_points: DataArray, - disjunctive: bool, - active: LinExprLike | None = None, - ) -> None: - self.expr = expr - self.x_points = x_points - self.y_points = y_points - self.disjunctive = disjunctive - self.active = active - - # y <= pw → Python tries y.__le__(pw) → NotImplemented → pw.__ge__(y) - def __ge__(self, other: LinExprLike) -> PiecewiseConstraintDescriptor: - return PiecewiseConstraintDescriptor(lhs=other, sign="<=", piecewise_func=self) - - # y >= pw → Python tries y.__ge__(pw) → NotImplemented → pw.__le__(y) - def __le__(self, other: LinExprLike) -> PiecewiseConstraintDescriptor: - return PiecewiseConstraintDescriptor(lhs=other, sign=">=", piecewise_func=self) - - # y == pw → Python tries y.__eq__(pw) → NotImplemented → pw.__eq__(y) - def __eq__(self, other: object) -> PiecewiseConstraintDescriptor: # type: ignore[override] - from linopy.expressions import LinearExpression - from linopy.variables import Variable - - if not isinstance(other, Variable | LinearExpression): - return NotImplemented - return PiecewiseConstraintDescriptor(lhs=other, sign="==", piecewise_func=self) - - -@dataclass -class PiecewiseConstraintDescriptor: - """Holds all information needed to add a piecewise constraint to a model.""" - - lhs: LinExprLike - sign: str # "<=", ">=", "==" - piecewise_func: PiecewiseExpression - - -def _detect_disjunctive(x_points: DataArray, y_points: DataArray) -> bool: - """ - Detect whether point arrays represent a disjunctive formulation. - - Both ``x_points`` and ``y_points`` **must** use the well-known dimension - names ``BREAKPOINT_DIM`` and, for disjunctive formulations, - ``SEGMENT_DIM``. Use the :func:`breakpoints` / :func:`segments` factory - helpers to build arrays with the correct dimension names. - """ - x_has_bp = BREAKPOINT_DIM in x_points.dims - y_has_bp = BREAKPOINT_DIM in y_points.dims - if not x_has_bp and not y_has_bp: +def _validate_xy_points(x_points: DataArray, y_points: DataArray) -> bool: + """Validate x/y breakpoint arrays and return whether formulation is disjunctive.""" + if BREAKPOINT_DIM not in x_points.dims: raise ValueError( - "x_points and y_points must have a breakpoint dimension. " - f"Got x_points dims {list(x_points.dims)} and y_points dims " - f"{list(y_points.dims)}. Use the breakpoints() or segments() " - f"factory to create correctly-dimensioned arrays." - ) - if not x_has_bp: - raise ValueError( - "x_points is missing the breakpoint dimension, " + f"x_points is missing the '{BREAKPOINT_DIM}' dimension, " f"got dims {list(x_points.dims)}. " "Use the breakpoints() or segments() factory." ) - if not y_has_bp: + if BREAKPOINT_DIM not in y_points.dims: raise ValueError( - "y_points is missing the breakpoint dimension, " + f"y_points is missing the '{BREAKPOINT_DIM}' dimension, " f"got dims {list(y_points.dims)}. " "Use the breakpoints() or segments() factory." ) + if x_points.sizes[BREAKPOINT_DIM] != y_points.sizes[BREAKPOINT_DIM]: + raise ValueError( + f"x_points and y_points must have same size along '{BREAKPOINT_DIM}', " + f"got {x_points.sizes[BREAKPOINT_DIM]} and " + f"{y_points.sizes[BREAKPOINT_DIM]}" + ) + x_has_seg = SEGMENT_DIM in x_points.dims y_has_seg = SEGMENT_DIM in y_points.dims if x_has_seg != y_has_seg: @@ -459,64 +392,12 @@ def _detect_disjunctive(x_points: DataArray, y_points: DataArray) -> bool: f"both must. x_points dims: {list(x_points.dims)}, " f"y_points dims: {list(y_points.dims)}." ) - - return x_has_seg - - -def piecewise( - expr: LinExprLike, - x_points: BreaksLike, - y_points: BreaksLike, - active: LinExprLike | None = None, -) -> PiecewiseExpression: - """ - Create a piecewise linear function descriptor. - - Parameters - ---------- - expr : Variable or LinearExpression - The "x" side expression. - x_points : BreaksLike - Breakpoint x-coordinates. - y_points : BreaksLike - Breakpoint y-coordinates. - active : Variable or LinearExpression, optional - Binary variable that scales the piecewise function. When - ``active=0``, all auxiliary variables are forced to zero, which - in turn forces the reconstructed x and y to zero. When - ``active=1``, the normal piecewise domain ``[x₀, xₙ]`` is - active. This is the only behavior the linear formulation - supports — selectively *relaxing* the constraint (letting x and - y float freely when off) would require big-M or indicator - constraints. - - Returns - ------- - PiecewiseExpression - """ - if not isinstance(x_points, DataArray): - x_points = _coerce_breaks(x_points) - if not isinstance(y_points, DataArray): - y_points = _coerce_breaks(y_points) - - disjunctive = _detect_disjunctive(x_points, y_points) - - # Validate compatible shapes along breakpoint dimension - if x_points.sizes[BREAKPOINT_DIM] != y_points.sizes[BREAKPOINT_DIM]: + if x_has_seg and x_points.sizes[SEGMENT_DIM] != y_points.sizes[SEGMENT_DIM]: raise ValueError( - f"x_points and y_points must have same size along '{BREAKPOINT_DIM}', " - f"got {x_points.sizes[BREAKPOINT_DIM]} and " - f"{y_points.sizes[BREAKPOINT_DIM]}" + f"x_points and y_points must have same size along '{SEGMENT_DIM}'" ) - # Validate compatible shapes along segment dimension - if disjunctive: - if x_points.sizes[SEGMENT_DIM] != y_points.sizes[SEGMENT_DIM]: - raise ValueError( - f"x_points and y_points must have same size along '{SEGMENT_DIM}'" - ) - - return PiecewiseExpression(expr, x_points, y_points, disjunctive, active) + return x_has_seg # --------------------------------------------------------------------------- @@ -946,63 +827,191 @@ def _add_dpwl_sos2_core( # --------------------------------------------------------------------------- +@overload def add_piecewise_constraints( model: Model, - descriptor: PiecewiseConstraintDescriptor | Constraint, + x: LinExprLike, + y: LinExprLike, + x_points: BreaksLike, + y_points: BreaksLike, + *, + sign: str = "==", method: Literal["sos2", "incremental", "auto", "lp"] = "auto", + active: LinExprLike | None = None, name: str | None = None, skip_nan_check: bool = False, +) -> Constraint: ... + + +@overload +def add_piecewise_constraints( + model: Model, + *, + exprs: Mapping[str, LinExprLike], + breakpoints: DataArray, + method: Literal["sos2", "incremental", "auto"] = "auto", + name: str | None = None, + mask: DataArray | None = None, + skip_nan_check: bool = False, +) -> Constraint: ... + + +def add_piecewise_constraints( + model: Model, + *args: LinExprLike | BreaksLike, + # 2-variable keyword args + sign: str = "==", + active: LinExprLike | None = None, + # N-variable keyword args + exprs: Mapping[str, LinExprLike] | None = None, + breakpoints: DataArray | None = None, + mask: DataArray | None = None, + # Shared keyword args + method: Literal["sos2", "incremental", "auto", "lp"] = "auto", + name: str | None = None, + skip_nan_check: bool = False, + # Positional breakpoints for 2-variable case + x_points: BreaksLike | None = None, + y_points: BreaksLike | None = None, ) -> Constraint: """ - Add a piecewise linear constraint from a :class:`PiecewiseConstraintDescriptor`. + Add piecewise linear constraints. + + Supports two calling conventions: + + **2-variable (positional):** - Typically called as:: + Links two expressions ``x`` and ``y`` via separate x/y breakpoints:: - m.add_piecewise_constraints(piecewise(x, x_points, y_points) >= y) + m.add_piecewise_constraints(x, y, x_points, y_points, sign="==") + + **N-variable (keyword):** + + Links N expressions through shared breakpoints (a single DataArray + whose coordinates match the dict keys):: + + m.add_piecewise_constraints( + exprs={"power": power, "fuel": fuel, "heat": heat}, + breakpoints=bp, + ) Parameters ---------- model : Model The linopy model. - descriptor : PiecewiseConstraintDescriptor - Created by comparing a variable/expression with a :class:`PiecewiseExpression`. + x : Variable or LinearExpression + The "x" side expression (2-variable case). + y : Variable or LinearExpression + The "y" side expression (2-variable case). + x_points : BreaksLike + Breakpoint x-coordinates (2-variable case). + y_points : BreaksLike + Breakpoint y-coordinates (2-variable case). + sign : str, default "==" + Constraint sign: "==", "<=", or ">=" (2-variable case). + active : Variable or LinearExpression, optional + Binary variable that scales the piecewise function (2-variable case). + exprs : dict of str to Variable/LinearExpression + Expressions to link (N-variable case). + breakpoints : DataArray + Shared breakpoint array (N-variable case). + mask : DataArray, optional + Boolean mask for valid constraints. method : {"auto", "sos2", "incremental", "lp"}, default "auto" - Formulation method. + Formulation method. "lp" is only available for the 2-variable case. name : str, optional Base name for generated variables/constraints. skip_nan_check : bool, default False - If True, skip NaN detection. + If True, skip NaN detection in breakpoints. Returns ------- Constraint """ - if not isinstance(descriptor, PiecewiseConstraintDescriptor): + if exprs is not None: + # N-variable path + if breakpoints is None: + raise TypeError( + "N-variable call requires both 'exprs' and 'breakpoints' keywords." + ) + if method == "lp": + raise ValueError( + "Pure LP method is not supported for N-variable piecewise constraints. " + "Use method='sos2' or method='incremental'." + ) + return _add_piecewise_nvar( + model, + exprs=dict(exprs), + breakpoints_da=breakpoints, + method=method, + name=name, + mask=mask, + skip_nan_check=skip_nan_check, + ) + elif len(args) >= 2: + # 2-variable positional path: (x, y, x_points, y_points) + if len(args) == 4: + x_arg, y_arg, xp_arg, yp_arg = args + elif len(args) == 2 and x_points is not None and y_points is not None: + x_arg, y_arg = args + xp_arg, yp_arg = x_points, y_points + else: + raise TypeError( + "2-variable call requires 4 positional args: (x, y, x_points, y_points) " + "or 2 positional args with x_points= and y_points= keywords." + ) + return _add_piecewise_2var( + model, + x=x_arg, + y=y_arg, + x_points=xp_arg, + y_points=yp_arg, + sign=sign, + method=method, + active=active, + name=name, + skip_nan_check=skip_nan_check, + ) + else: raise TypeError( - f"Expected PiecewiseConstraintDescriptor, got {type(descriptor)}. " - f"Use: m.add_piecewise_constraints(piecewise(x, x_points, y_points) >= y)" + "add_piecewise_constraints() requires either:\n" + " - 2-variable: (x, y, x_points, y_points, sign=...)\n" + " - N-variable: (exprs={...}, breakpoints=...)" ) + +def _add_piecewise_2var( + model: Model, + x: LinExprLike, + y: LinExprLike, + x_points: BreaksLike, + y_points: BreaksLike, + sign: str = "==", + method: str = "auto", + active: LinExprLike | None = None, + name: str | None = None, + skip_nan_check: bool = False, +) -> Constraint: + """2-variable piecewise constraint: y sign f(x).""" if method not in ("sos2", "incremental", "auto", "lp"): raise ValueError( f"method must be 'sos2', 'incremental', 'auto', or 'lp', got '{method}'" ) - pw = descriptor.piecewise_func - sign = descriptor.sign - y_lhs = descriptor.lhs - x_expr_raw = pw.expr - x_points = pw.x_points - y_points = pw.y_points - disjunctive = pw.disjunctive - active = pw.active + # Coerce breakpoints + if not isinstance(x_points, DataArray): + x_points = _coerce_breaks(x_points) + if not isinstance(y_points, DataArray): + y_points = _coerce_breaks(y_points) + + disjunctive = _validate_xy_points(x_points, y_points) # Broadcast points to match expression dimensions - x_points = _broadcast_points(x_points, x_expr_raw, y_lhs, disjunctive=disjunctive) - y_points = _broadcast_points(y_points, x_expr_raw, y_lhs, disjunctive=disjunctive) + x_points = _broadcast_points(x_points, x, y, disjunctive=disjunctive) + y_points = _broadcast_points(y_points, x, y, disjunctive=disjunctive) # Compute mask - mask = _compute_combined_mask(x_points, y_points, skip_nan_check) + bp_mask = _compute_combined_mask(x_points, y_points, skip_nan_check) # Name if name is None: @@ -1010,13 +1019,10 @@ def add_piecewise_constraints( model._pwlCounter += 1 # Convert to LinearExpressions - x_expr = _to_linexpr(x_expr_raw) - y_expr = _to_linexpr(y_lhs) - - # Convert active to LinearExpression if provided + x_expr = _to_linexpr(x) + y_expr = _to_linexpr(y) active_expr = _to_linexpr(active) if active is not None else None - # Validate: active is not supported with LP method if active_expr is not None and method == "lp": raise ValueError( "The 'active' parameter is not supported with method='lp'. " @@ -1032,7 +1038,7 @@ def add_piecewise_constraints( sign, x_points, y_points, - mask, + bp_mask, method, active_expr, ) @@ -1045,13 +1051,233 @@ def add_piecewise_constraints( sign, x_points, y_points, - mask, + bp_mask, method, skip_nan_check, active_expr, ) +# --------------------------------------------------------------------------- +# N-variable path (shared-lambda linking) +# --------------------------------------------------------------------------- + + +def _resolve_link_dim( + bp: DataArray, + expr_keys: set[str], + exclude_dims: set[str], +) -> str: + """Auto-detect the linking dimension from breakpoints.""" + for d in bp.dims: + if d in exclude_dims: + continue + coord_set = {str(c) for c in bp.coords[d].values} + if coord_set == expr_keys: + return str(d) + raise ValueError( + "Could not auto-detect linking dimension from breakpoints. " + "Ensure breakpoints have a dimension whose coordinates match " + f"the expression dict keys. " + f"Breakpoint dimensions: {list(bp.dims)}, " + f"expression keys: {list(expr_keys)}" + ) + + +def _build_stacked_expr( + model: Model, + expr_dict: dict[str, LinExprLike], + bp: DataArray, + link_dim: str, +) -> LinearExpression: + """Stack expressions along the link dimension.""" + from linopy.expressions import LinearExpression + + link_coords = list(bp.coords[link_dim].values) + expr_data_list = [] + for k in link_coords: + e = expr_dict[str(k)] + linexpr = _to_linexpr(e) + expr_data_list.append(linexpr.data.expand_dims({link_dim: [k]})) + + stacked_data = xr.concat(expr_data_list, dim=link_dim) + return LinearExpression(stacked_data, model) + + +def _add_pwl_sos2_nvar( + model: Model, + name: str, + bp: DataArray, + dim: str, + target_expr: LinearExpression, + lambda_coords: list[pd.Index], + lambda_mask: DataArray | None, +) -> Constraint: + """SOS2 formulation for N-variable linking.""" + lambda_name = f"{name}{PWL_LAMBDA_SUFFIX}" + convex_name = f"{name}{PWL_CONVEX_SUFFIX}" + link_name = f"{name}{PWL_X_LINK_SUFFIX}" + + lambda_var = model.add_variables( + lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=lambda_mask + ) + + model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=dim) + + model.add_constraints(lambda_var.sum(dim=dim) == 1, name=convex_name) + + weighted_sum = (lambda_var * bp).sum(dim=dim) + return model.add_constraints(target_expr == weighted_sum, name=link_name) + + +def _add_pwl_incremental_nvar( + model: Model, + name: str, + bp: DataArray, + dim: str, + target_expr: LinearExpression, + extra_coords: list[pd.Index], + bp_mask: DataArray | None, + link_dim: str | None, +) -> Constraint: + """Incremental formulation for N-variable linking.""" + delta_name = f"{name}{PWL_DELTA_SUFFIX}" + fill_name = f"{name}{PWL_FILL_SUFFIX}" + link_name = f"{name}{PWL_X_LINK_SUFFIX}" + + n_segments = bp.sizes[dim] - 1 + seg_dim = f"{dim}_seg" + seg_index = pd.Index(range(n_segments), name=seg_dim) + delta_coords = extra_coords + [seg_index] + + steps = bp.diff(dim).rename({dim: seg_dim}) + steps[seg_dim] = seg_index + + if bp_mask is not None: + bp_mask_agg = bp_mask + if link_dim is not None: + bp_mask_agg = bp_mask_agg.all(dim=link_dim) + mask_lo = bp_mask_agg.isel({dim: slice(None, -1)}).rename({dim: seg_dim}) + mask_hi = bp_mask_agg.isel({dim: slice(1, None)}).rename({dim: seg_dim}) + mask_lo[seg_dim] = seg_index + mask_hi[seg_dim] = seg_index + delta_mask: DataArray | None = mask_lo & mask_hi + else: + delta_mask = None + + delta_var = model.add_variables( + lower=0, upper=1, coords=delta_coords, name=delta_name, mask=delta_mask + ) + + fill_con: Constraint | None = None + if n_segments >= 2: + delta_lo = delta_var.isel({seg_dim: slice(None, -1)}, drop=True) + delta_hi = delta_var.isel({seg_dim: slice(1, None)}, drop=True) + fill_con = model.add_constraints(delta_hi <= delta_lo, name=fill_name) + + bp0 = bp.isel({dim: 0}) + weighted_sum = (delta_var * steps).sum(dim=seg_dim) + bp0 + link_con = model.add_constraints(target_expr == weighted_sum, name=link_name) + + return fill_con if fill_con is not None else link_con + + +def _compute_mask_nvar( + mask: DataArray | None, + bp: DataArray, + skip_nan_check: bool, +) -> DataArray | None: + """Compute mask from NaN values in breakpoints (N-variable path).""" + if skip_nan_check: + if bool(bp.isnull().any()): + raise ValueError( + "skip_nan_check=True but breakpoints contain NaN. " + "Either remove NaN values or set skip_nan_check=False." + ) + return mask + nan_mask = ~bp.isnull() + if mask is not None: + return mask & nan_mask + return nan_mask if bool(bp.isnull().any()) else None + + +def _add_piecewise_nvar( + model: Model, + exprs: dict[str, LinExprLike], + breakpoints_da: DataArray, + method: str = "auto", + name: str | None = None, + mask: DataArray | None = None, + skip_nan_check: bool = False, +) -> Constraint: + """N-variable piecewise constraint with shared lambdas.""" + if method not in ("sos2", "incremental", "auto"): + raise ValueError( + f"method must be 'sos2', 'incremental', or 'auto', got '{method}'" + ) + + dim = BREAKPOINT_DIM + if dim not in breakpoints_da.dims: + raise ValueError( + f"breakpoints must have a '{dim}' dimension. " + f"Got dims {list(breakpoints_da.dims)}. " + "Use the breakpoints() factory to create the array." + ) + + # Auto-detect method + if method in ("incremental", "auto"): + is_monotonic = _check_strict_monotonicity(breakpoints_da) + trailing_nan_only = _has_trailing_nan_only(breakpoints_da) + if method == "auto": + method = "incremental" if (is_monotonic and trailing_nan_only) else "sos2" + elif not is_monotonic: + raise ValueError( + "Incremental method requires strictly monotonic breakpoints." + ) + if method == "incremental" and not trailing_nan_only: + raise ValueError( + "Incremental method does not support non-trailing NaN breakpoints." + ) + + if method == "sos2": + _validate_numeric_breakpoint_coords(breakpoints_da) + + if name is None: + name = f"pwl{model._pwlCounter}" + model._pwlCounter += 1 + + # Resolve expressions and linking dimension + expr_keys = set(exprs.keys()) + link_dim = _resolve_link_dim(breakpoints_da, expr_keys, {dim}) + computed_mask = _compute_mask_nvar(mask, breakpoints_da, skip_nan_check) + + lambda_mask = None + if computed_mask is not None: + if link_dim not in computed_mask.dims: + computed_mask = computed_mask.broadcast_like(breakpoints_da) + lambda_mask = computed_mask.any(dim=link_dim) + + target_expr = _build_stacked_expr(model, exprs, breakpoints_da, link_dim) + extra = _extra_coords(breakpoints_da, dim, link_dim) + lambda_coords = extra + [pd.Index(breakpoints_da.coords[dim].values, name=dim)] + + if method == "sos2": + return _add_pwl_sos2_nvar( + model, name, breakpoints_da, dim, target_expr, lambda_coords, lambda_mask + ) + else: + return _add_pwl_incremental_nvar( + model, + name, + breakpoints_da, + dim, + target_expr, + extra, + computed_mask, + link_dim, + ) + + def _add_continuous( model: Model, name: str, diff --git a/linopy/types.py b/linopy/types.py index 7238c552..0e3662bf 100644 --- a/linopy/types.py +++ b/linopy/types.py @@ -17,7 +17,6 @@ QuadraticExpression, ScalarLinearExpression, ) - from linopy.piecewise import PiecewiseConstraintDescriptor from linopy.variables import ScalarVariable, Variable # Type aliases using Union for Python 3.9 compatibility @@ -47,9 +46,7 @@ "LinearExpression", "QuadraticExpression", ] -ConstraintLike = Union[ - "Constraint", "AnonymousScalarConstraint", "PiecewiseConstraintDescriptor" -] +ConstraintLike = Union["Constraint", "AnonymousScalarConstraint"] LinExprLike = Union["Variable", "LinearExpression"] MaskLike = Union[numpy.ndarray, DataArray, Series, DataFrame] # noqa: UP007 SideLike = Union[ConstantLike, VariableLike, ExpressionLike] # noqa: UP007 diff --git a/linopy/variables.py b/linopy/variables.py index 51f57a6d..692ef9ba 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -79,7 +79,6 @@ ScalarLinearExpression, ) from linopy.model import Model - from linopy.piecewise import PiecewiseConstraintDescriptor, PiecewiseExpression logger = logging.getLogger(__name__) @@ -537,31 +536,13 @@ def __rsub__(self, other: ConstantLike) -> LinearExpression: except TypeError: return NotImplemented - @overload - def __le__(self, other: PiecewiseExpression) -> PiecewiseConstraintDescriptor: ... - - @overload - def __le__(self, other: SideLike) -> Constraint: ... - - def __le__(self, other: SideLike) -> Constraint | PiecewiseConstraintDescriptor: + def __le__(self, other: SideLike) -> Constraint: return self.to_linexpr().__le__(other) - @overload - def __ge__(self, other: PiecewiseExpression) -> PiecewiseConstraintDescriptor: ... - - @overload - def __ge__(self, other: SideLike) -> Constraint: ... - - def __ge__(self, other: SideLike) -> Constraint | PiecewiseConstraintDescriptor: + def __ge__(self, other: SideLike) -> Constraint: return self.to_linexpr().__ge__(other) - @overload # type: ignore[override] - def __eq__(self, other: PiecewiseExpression) -> PiecewiseConstraintDescriptor: ... - - @overload - def __eq__(self, other: SideLike) -> Constraint: ... - - def __eq__(self, other: SideLike) -> Constraint | PiecewiseConstraintDescriptor: + def __eq__(self, other: SideLike) -> Constraint: return self.to_linexpr().__eq__(other) def __gt__(self, other: Any) -> NotImplementedType: diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index ab8e1f09..15ab3aac 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -13,7 +13,6 @@ Model, available_solvers, breakpoints, - piecewise, segments, slopes_to_points, ) @@ -37,10 +36,6 @@ PWL_Y_LINK_SUFFIX, SEGMENT_DIM, ) -from linopy.piecewise import ( - PiecewiseConstraintDescriptor, - PiecewiseExpression, -) from linopy.solver_capabilities import SolverFeature, get_available_solvers_with_feature _sos2_solvers = get_available_solvers_with_feature( @@ -281,168 +276,7 @@ def test_dataarray_missing_dim_raises(self) -> None: # =========================================================================== -# piecewise() and operator overloading -# =========================================================================== - - -class TestPiecewiseFunction: - def test_returns_expression(self) -> None: - m = Model() - x = m.add_variables(name="x") - pw = piecewise(x, x_points=[0, 10, 50], y_points=[5, 2, 20]) - assert isinstance(pw, PiecewiseExpression) - - def test_series_inputs(self) -> None: - m = Model() - x = m.add_variables(name="x") - pw = piecewise(x, pd.Series([0, 10, 50]), pd.Series([5, 2, 20])) - assert isinstance(pw, PiecewiseExpression) - - def test_tuple_inputs(self) -> None: - m = Model() - x = m.add_variables(name="x") - pw = piecewise(x, (0, 10, 50), (5, 2, 20)) - assert isinstance(pw, PiecewiseExpression) - - def test_eq_returns_descriptor(self) -> None: - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - desc = piecewise(x, [0, 10, 50], [5, 2, 20]) == y - assert isinstance(desc, PiecewiseConstraintDescriptor) - assert desc.sign == "==" - - def test_ge_returns_le_descriptor(self) -> None: - """Pw >= y means y <= pw""" - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - desc = piecewise(x, [0, 10, 50], [5, 2, 20]) >= y - assert isinstance(desc, PiecewiseConstraintDescriptor) - assert desc.sign == "<=" - - def test_le_returns_ge_descriptor(self) -> None: - """Pw <= y means y >= pw""" - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - desc = piecewise(x, [0, 10, 50], [5, 2, 20]) <= y - assert isinstance(desc, PiecewiseConstraintDescriptor) - assert desc.sign == ">=" - - @pytest.mark.parametrize( - ("operator", "expected_sign"), - [("==", "=="), ("<=", "<="), (">=", ">=")], - ) - def test_rhs_piecewise_returns_descriptor( - self, operator: str, expected_sign: str - ) -> None: - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - pw = piecewise(x, [0, 10, 50], [5, 2, 20]) - - if operator == "==": - desc = y == pw - elif operator == "<=": - desc = y <= pw - else: - desc = y >= pw - - assert isinstance(desc, PiecewiseConstraintDescriptor) - assert desc.sign == expected_sign - assert desc.piecewise_func is pw - - @pytest.mark.parametrize( - ("operator", "expected_sign"), - [("==", "=="), ("<=", "<="), (">=", ">=")], - ) - def test_rhs_piecewise_linear_expression_returns_descriptor( - self, operator: str, expected_sign: str - ) -> None: - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - z = m.add_variables(name="z") - lhs = 2 * y + z - pw = piecewise(x, [0, 10, 50], [5, 2, 20]) - - if operator == "==": - desc = lhs == pw - elif operator == "<=": - desc = lhs <= pw - else: - desc = lhs >= pw - - assert isinstance(desc, PiecewiseConstraintDescriptor) - assert desc.sign == expected_sign - assert desc.lhs is lhs - assert desc.piecewise_func is pw - - def test_rhs_piecewise_add_constraint(self) -> None: - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - m.add_piecewise_constraints(y == piecewise(x, [0, 10, 50], [5, 2, 20])) - assert len(m.constraints) > 0 - - def test_mismatched_sizes_raises(self) -> None: - m = Model() - x = m.add_variables(name="x") - with pytest.raises(ValueError, match="same size"): - piecewise(x, [0, 10, 50, 100], [5, 2, 20]) - - def test_missing_breakpoint_dim_raises(self) -> None: - m = Model() - x = m.add_variables(name="x") - xp = xr.DataArray([0, 10, 50], dims=["knot"]) - yp = xr.DataArray([5, 2, 20], dims=["knot"]) - with pytest.raises(ValueError, match="must have a breakpoint dimension"): - piecewise(x, xp, yp) - - def test_missing_breakpoint_dim_x_only_raises(self) -> None: - m = Model() - x = m.add_variables(name="x") - xp = xr.DataArray([0, 10, 50], dims=["knot"]) - yp = xr.DataArray([5, 2, 20], dims=[BREAKPOINT_DIM]) - with pytest.raises( - ValueError, match="x_points is missing the breakpoint dimension" - ): - piecewise(x, xp, yp) - - def test_missing_breakpoint_dim_y_only_raises(self) -> None: - m = Model() - x = m.add_variables(name="x") - xp = xr.DataArray([0, 10, 50], dims=[BREAKPOINT_DIM]) - yp = xr.DataArray([5, 2, 20], dims=["knot"]) - with pytest.raises( - ValueError, match="y_points is missing the breakpoint dimension" - ): - piecewise(x, xp, yp) - - def test_segment_dim_mismatch_raises(self) -> None: - m = Model() - x = m.add_variables(name="x") - xp = segments([[0, 10], [50, 100]]) - yp = xr.DataArray([0, 5], dims=[BREAKPOINT_DIM]) - with pytest.raises(ValueError, match="segment.*dimension.*both must"): - piecewise(x, xp, yp) - - def test_detects_disjunctive(self) -> None: - m = Model() - x = m.add_variables(name="x") - pw = piecewise(x, segments([[0, 10], [50, 100]]), segments([[0, 5], [20, 80]])) - assert pw.disjunctive is True - - def test_detects_continuous(self) -> None: - m = Model() - x = m.add_variables(name="x") - pw = piecewise(x, [0, 10, 50], [5, 2, 20]) - assert pw.disjunctive is False - - -# =========================================================================== -# Continuous piecewise – equality +# Continuous piecewise -- equality # =========================================================================== @@ -452,7 +286,10 @@ def test_sos2(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, [0, 10, 50, 100], [5, 2, 20, 80]) == y, + x, + y, + [0, 10, 50, 100], + [5, 2, 20, 80], method="sos2", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables @@ -467,7 +304,10 @@ def test_auto_selects_incremental_for_monotonic(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, [0, 10, 50, 100], [5, 2, 20, 80]) == y, + x, + y, + [0, 10, 50, 100], + [5, 2, 20, 80], ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables assert f"pwl0{PWL_LAMBDA_SUFFIX}" not in m.variables @@ -477,7 +317,10 @@ def test_auto_nonmonotonic_falls_back_to_sos2(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, [0, 50, 30, 100], [5, 20, 15, 80]) == y, + x, + y, + [0, 50, 30, 100], + [5, 20, 15, 80], ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables assert f"pwl0{PWL_DELTA_SUFFIX}" not in m.variables @@ -488,16 +331,10 @@ def test_multi_dimensional(self) -> None: x = m.add_variables(coords=[gens], name="x") y = m.add_variables(coords=[gens], name="y") m.add_piecewise_constraints( - piecewise( - x, - breakpoints( - {"gen_a": [0, 10, 50], "gen_b": [0, 20, 80]}, dim="generator" - ), - breakpoints( - {"gen_a": [0, 5, 30], "gen_b": [0, 8, 50]}, dim="generator" - ), - ) - == y, + x, + y, + breakpoints({"gen_a": [0, 10, 50], "gen_b": [0, 20, 80]}, dim="generator"), + breakpoints({"gen_a": [0, 5, 30], "gen_b": [0, 8, 50]}, dim="generator"), ) delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"] assert "generator" in delta.dims @@ -507,12 +344,10 @@ def test_with_slopes(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise( - x, - [0, 10, 50, 100], - breakpoints(slopes=[-0.3, 0.45, 1.2], x_points=[0, 10, 50, 100], y0=5), - ) - == y, + x, + y, + [0, 10, 50, 100], + breakpoints(slopes=[-0.3, 0.45, 1.2], x_points=[0, 10, 50, 100], y0=5), ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables @@ -524,53 +359,69 @@ def test_with_slopes(self) -> None: class TestContinuousInequality: def test_concave_le_uses_lp(self) -> None: - """Y <= concave f(x) → LP tangent lines""" + """Y <= concave f(x) -> LP tangent lines""" m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") # Concave: slopes 0.8, 0.4 (decreasing) - # pw >= y means y <= pw (sign="<=") + # y <= pw -> sign="<=" m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 40, 60]) >= y, + x, + y, + [0, 50, 100], + [0, 40, 60], + sign="<=", ) assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints assert f"pwl0{PWL_LAMBDA_SUFFIX}" not in m.variables assert f"pwl0{PWL_AUX_SUFFIX}" not in m.variables def test_convex_le_uses_sos2_aux(self) -> None: - """Y <= convex f(x) → SOS2 + aux""" + """Y <= convex f(x) -> SOS2 + aux""" m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") # Convex: slopes 0.2, 1.0 (increasing) m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 10, 60]) >= y, + x, + y, + [0, 50, 100], + [0, 10, 60], + sign="<=", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables def test_convex_ge_uses_lp(self) -> None: - """Y >= convex f(x) → LP tangent lines""" + """Y >= convex f(x) -> LP tangent lines""" m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") # Convex: slopes 0.2, 1.0 (increasing) - # pw <= y means y >= pw (sign=">=") + # y >= pw -> sign=">=" m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 10, 60]) <= y, + x, + y, + [0, 50, 100], + [0, 10, 60], + sign=">=", ) assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints assert f"pwl0{PWL_LAMBDA_SUFFIX}" not in m.variables assert f"pwl0{PWL_AUX_SUFFIX}" not in m.variables def test_concave_ge_uses_sos2_aux(self) -> None: - """Y >= concave f(x) → SOS2 + aux""" + """Y >= concave f(x) -> SOS2 + aux""" m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") # Concave: slopes 0.8, 0.4 (decreasing) m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 40, 60]) <= y, + x, + y, + [0, 50, 100], + [0, 40, 60], + sign=">=", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables @@ -581,7 +432,11 @@ def test_mixed_uses_sos2(self) -> None: y = m.add_variables(name="y") # Mixed: slopes 0.5, 0.3, 0.9 (down then up) m.add_piecewise_constraints( - piecewise(x, [0, 30, 60, 100], [0, 15, 24, 60]) >= y, + x, + y, + [0, 30, 60, 100], + [0, 15, 24, 60], + sign="<=", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables @@ -593,7 +448,11 @@ def test_method_lp_wrong_convexity_raises(self) -> None: # Convex function + y <= pw + method="lp" should fail with pytest.raises(ValueError, match="convex"): m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 10, 60]) >= y, + x, + y, + [0, 50, 100], + [0, 10, 60], + sign="<=", method="lp", ) @@ -603,7 +462,11 @@ def test_method_lp_decreasing_breakpoints_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="strictly increasing x_points"): m.add_piecewise_constraints( - piecewise(x, [100, 50, 0], [60, 10, 0]) <= y, + x, + y, + [100, 50, 0], + [60, 10, 0], + sign=">=", method="lp", ) @@ -613,7 +476,11 @@ def test_auto_inequality_decreasing_breakpoints_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="strictly increasing x_points"): m.add_piecewise_constraints( - piecewise(x, [100, 50, 0], [60, 10, 0]) <= y, + x, + y, + [100, 50, 0], + [60, 10, 0], + sign=">=", ) def test_method_lp_equality_raises(self) -> None: @@ -622,7 +489,10 @@ def test_method_lp_equality_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="equality"): m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 40, 60]) == y, + x, + y, + [0, 50, 100], + [0, 40, 60], method="lp", ) @@ -638,7 +508,10 @@ def test_creates_delta_vars(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, [0, 10, 50, 100], [5, 2, 20, 80]) == y, + x, + y, + [0, 10, 50, 100], + [5, 2, 20, 80], method="incremental", ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables @@ -653,7 +526,10 @@ def test_nonmonotonic_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="strictly monotonic"): m.add_piecewise_constraints( - piecewise(x, [0, 50, 30, 100], [5, 20, 15, 80]) == y, + x, + y, + [0, 50, 30, 100], + [5, 20, 15, 80], method="incremental", ) @@ -662,7 +538,10 @@ def test_sos2_nonmonotonic_succeeds(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, [0, 50, 30, 100], [5, 20, 15, 80]) == y, + x, + y, + [0, 50, 30, 100], + [5, 20, 15, 80], method="sos2", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables @@ -673,7 +552,10 @@ def test_two_breakpoints_no_fill(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, [0, 100], [5, 80]) == y, + x, + y, + [0, 100], + [5, 80], method="incremental", ) delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"] @@ -686,7 +568,10 @@ def test_creates_binary_indicator_vars(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, [0, 10, 50, 100], [5, 2, 20, 80]) == y, + x, + y, + [0, 10, 50, 100], + [5, 2, 20, 80], method="incremental", ) assert f"pwl0{PWL_INC_BINARY_SUFFIX}" in m.variables @@ -699,7 +584,10 @@ def test_creates_order_constraints(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, [0, 10, 50, 100], [5, 2, 20, 80]) == y, + x, + y, + [0, 10, 50, 100], + [5, 2, 20, 80], method="incremental", ) assert f"pwl0{PWL_INC_ORDER_SUFFIX}" in m.constraints @@ -710,7 +598,10 @@ def test_two_breakpoints_no_order_constraint(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, [0, 100], [5, 80]) == y, + x, + y, + [0, 100], + [5, 80], method="incremental", ) assert f"pwl0{PWL_INC_BINARY_SUFFIX}" in m.variables @@ -722,7 +613,10 @@ def test_decreasing_monotonic(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, [100, 50, 10, 0], [80, 20, 2, 5]) == y, + x, + y, + [100, 50, 10, 0], + [80, 20, 2, 5], method="incremental", ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables @@ -739,8 +633,10 @@ def test_equality_creates_binary(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, segments([[0, 10], [50, 100]]), segments([[0, 5], [20, 80]])) - == y, + x, + y, + segments([[0, 10], [50, 100]]), + segments([[0, 5], [20, 80]]), ) assert f"pwl0{PWL_BINARY_SUFFIX}" in m.variables assert f"pwl0{PWL_SELECT_SUFFIX}" in m.constraints @@ -754,8 +650,11 @@ def test_inequality_creates_aux(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, segments([[0, 10], [50, 100]]), segments([[0, 5], [20, 80]])) - >= y, + x, + y, + segments([[0, 10], [50, 100]]), + segments([[0, 5], [20, 80]]), + sign="<=", ) assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables assert f"pwl0{PWL_BINARY_SUFFIX}" in m.variables @@ -767,10 +666,11 @@ def test_method_lp_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="disjunctive"): m.add_piecewise_constraints( - piecewise( - x, segments([[0, 10], [50, 100]]), segments([[0, 5], [20, 80]]) - ) - >= y, + x, + y, + segments([[0, 10], [50, 100]]), + segments([[0, 5], [20, 80]]), + sign="<=", method="lp", ) @@ -780,10 +680,10 @@ def test_method_incremental_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="disjunctive"): m.add_piecewise_constraints( - piecewise( - x, segments([[0, 10], [50, 100]]), segments([[0, 5], [20, 80]]) - ) - == y, + x, + y, + segments([[0, 10], [50, 100]]), + segments([[0, 5], [20, 80]]), method="incremental", ) @@ -793,18 +693,16 @@ def test_multi_dimensional(self) -> None: x = m.add_variables(coords=[gens], name="x") y = m.add_variables(coords=[gens], name="y") m.add_piecewise_constraints( - piecewise( - x, - segments( - {"gen_a": [[0, 10], [50, 100]], "gen_b": [[0, 20], [60, 90]]}, - dim="generator", - ), - segments( - {"gen_a": [[0, 5], [20, 80]], "gen_b": [[0, 8], [30, 70]]}, - dim="generator", - ), - ) - == y, + x, + y, + segments( + {"gen_a": [[0, 10], [50, 100]], "gen_b": [[0, 20], [60, 90]]}, + dim="generator", + ), + segments( + {"gen_a": [[0, 5], [20, 80]], "gen_b": [[0, 8], [30, 70]]}, + dim="generator", + ), ) binary = m.variables[f"pwl0{PWL_BINARY_SUFFIX}"] lam = m.variables[f"pwl0{PWL_LAMBDA_SUFFIX}"] @@ -818,10 +716,10 @@ def test_multi_dimensional(self) -> None: class TestValidation: - def test_non_descriptor_raises(self) -> None: + def test_wrong_arg_types_raises(self) -> None: m = Model() x = m.add_variables(name="x") - with pytest.raises(TypeError, match="PiecewiseConstraintDescriptor"): + with pytest.raises(TypeError, match="requires either"): m.add_piecewise_constraints(x) # type: ignore def test_invalid_method_raises(self) -> None: @@ -830,7 +728,10 @@ def test_invalid_method_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="method must be"): m.add_piecewise_constraints( - piecewise(x, [0, 10, 50], [5, 2, 20]) == y, + x, + y, + [0, 10, 50], + [5, 2, 20], method="invalid", # type: ignore ) @@ -846,8 +747,8 @@ def test_auto_name(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") z = m.add_variables(name="z") - m.add_piecewise_constraints(piecewise(x, [0, 10, 50], [5, 2, 20]) == y) - m.add_piecewise_constraints(piecewise(x, [0, 20, 80], [10, 15, 50]) == z) + m.add_piecewise_constraints(x, y, [0, 10, 50], [5, 2, 20]) + m.add_piecewise_constraints(x, z, [0, 20, 80], [10, 15, 50]) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables assert f"pwl1{PWL_DELTA_SUFFIX}" in m.variables @@ -856,7 +757,10 @@ def test_custom_name(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, [0, 10, 50], [5, 2, 20]) == y, + x, + y, + [0, 10, 50], + [5, 2, 20], name="my_pwl", ) assert f"my_pwl{PWL_DELTA_SUFFIX}" in m.variables @@ -876,18 +780,12 @@ def test_broadcast_over_extra_dims(self) -> None: times = pd.Index([0, 1, 2], name="time") x = m.add_variables(coords=[gens, times], name="x") y = m.add_variables(coords=[gens, times], name="y") - # Points only have generator dim → broadcast over time + # Points only have generator dim -> broadcast over time m.add_piecewise_constraints( - piecewise( - x, - breakpoints( - {"gen_a": [0, 10, 50], "gen_b": [0, 20, 80]}, dim="generator" - ), - breakpoints( - {"gen_a": [0, 5, 30], "gen_b": [0, 8, 50]}, dim="generator" - ), - ) - == y, + x, + y, + breakpoints({"gen_a": [0, 10, 50], "gen_b": [0, 20, 80]}, dim="generator"), + breakpoints({"gen_a": [0, 5, 30], "gen_b": [0, 8, 50]}, dim="generator"), ) delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"] assert "generator" in delta.dims @@ -908,7 +806,10 @@ def test_nan_masks_lambda_labels(self) -> None: x_pts = xr.DataArray([0, 10, 50, np.nan], dims=[BREAKPOINT_DIM]) y_pts = xr.DataArray([0, 5, 20, np.nan], dims=[BREAKPOINT_DIM]) m.add_piecewise_constraints( - piecewise(x, x_pts, y_pts) == y, + x, + y, + x_pts, + y_pts, method="sos2", ) lam = m.variables[f"pwl0{PWL_LAMBDA_SUFFIX}"] @@ -925,7 +826,10 @@ def test_skip_nan_check_with_nan_raises(self) -> None: y_pts = xr.DataArray([0, 5, 20, np.nan], dims=[BREAKPOINT_DIM]) with pytest.raises(ValueError, match="skip_nan_check=True but breakpoints"): m.add_piecewise_constraints( - piecewise(x, x_pts, y_pts) == y, + x, + y, + x_pts, + y_pts, method="sos2", skip_nan_check=True, ) @@ -938,7 +842,10 @@ def test_skip_nan_check_without_nan(self) -> None: x_pts = xr.DataArray([0, 10, 50, 100], dims=[BREAKPOINT_DIM]) y_pts = xr.DataArray([0, 5, 20, 40], dims=[BREAKPOINT_DIM]) m.add_piecewise_constraints( - piecewise(x, x_pts, y_pts) == y, + x, + y, + x_pts, + y_pts, method="sos2", skip_nan_check=True, ) @@ -954,7 +861,10 @@ def test_sos2_interior_nan_raises(self) -> None: y_pts = xr.DataArray([0, np.nan, 20, 40], dims=[BREAKPOINT_DIM]) with pytest.raises(ValueError, match="non-trailing NaN"): m.add_piecewise_constraints( - piecewise(x, x_pts, y_pts) == y, + x, + y, + x_pts, + y_pts, method="sos2", ) @@ -971,14 +881,22 @@ def test_linear_uses_lp_both_directions(self) -> None: x = m.add_variables(lower=0, upper=100, name="x") y1 = m.add_variables(name="y1") y2 = m.add_variables(name="y2") - # y1 >= f(x) → LP + # y1 >= f(x) -> LP m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 25, 50]) <= y1, + x, + y1, + [0, 50, 100], + [0, 25, 50], + sign=">=", ) assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints - # y2 <= f(x) → also LP (linear is both convex and concave) + # y2 <= f(x) -> also LP (linear is both convex and concave) m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 25, 50]) >= y2, + x, + y2, + [0, 50, 100], + [0, 25, 50], + sign="<=", ) assert f"pwl1{PWL_LP_SUFFIX}" in m.constraints @@ -988,7 +906,11 @@ def test_single_segment_uses_lp(self) -> None: x = m.add_variables(lower=0, upper=100, name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, [0, 100], [0, 50]) <= y, + x, + y, + [0, 100], + [0, 50], + sign=">=", ) assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints @@ -997,10 +919,14 @@ def test_mixed_convexity_uses_sos2(self) -> None: m = Model() x = m.add_variables(lower=0, upper=100, name="x") y = m.add_variables(name="y") - # Mixed: slope goes up then down → neither convex nor concave - # y <= f(x) → piecewise >= y → sign="<=" internally + # Mixed: slope goes up then down -> neither convex nor concave + # y <= f(x) -> sign="<=" m.add_piecewise_constraints( - piecewise(x, [0, 30, 60, 100], [0, 40, 30, 50]) >= y, + x, + y, + [0, 30, 60, 100], + [0, 40, 30, 50], + sign="<=", ) assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables @@ -1017,7 +943,10 @@ def test_sos2_equality(self, tmp_path: Path) -> None: x = m.add_variables(name="x", lower=0, upper=100) y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, [0.0, 10.0, 50.0, 100.0], [5.0, 2.0, 20.0, 80.0]) == y, + x, + y, + [0.0, 10.0, 50.0, 100.0], + [5.0, 2.0, 20.0, 80.0], method="sos2", ) m.add_objective(y) @@ -1031,9 +960,13 @@ def test_lp_formulation_no_sos2(self, tmp_path: Path) -> None: m = Model() x = m.add_variables(name="x", lower=0, upper=100) y = m.add_variables(name="y") - # Concave: pw >= y uses LP + # Concave: y <= pw uses LP m.add_piecewise_constraints( - piecewise(x, [0.0, 50.0, 100.0], [0.0, 40.0, 60.0]) >= y, + x, + y, + [0.0, 50.0, 100.0], + [0.0, 40.0, 60.0], + sign="<=", ) m.add_objective(y) fn = tmp_path / "pwl_lp.lp" @@ -1046,12 +979,10 @@ def test_disjunctive_sos2_and_binary(self, tmp_path: Path) -> None: x = m.add_variables(name="x", lower=0, upper=100) y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise( - x, - segments([[0.0, 10.0], [50.0, 100.0]]), - segments([[0.0, 5.0], [20.0, 80.0]]), - ) - == y, + x, + y, + segments([[0.0, 10.0], [50.0, 100.0]]), + segments([[0.0, 5.0], [20.0, 80.0]]), ) m.add_objective(y) fn = tmp_path / "pwl_disj.lp" @@ -1077,7 +1008,10 @@ def test_equality_minimize_cost(self, solver_name: str) -> None: x = m.add_variables(lower=0, upper=100, name="x") cost = m.add_variables(name="cost") m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 10, 50]) == cost, + x, + cost, + [0, 50, 100], + [0, 10, 50], ) m.add_constraints(x >= 50, name="x_min") m.add_objective(cost) @@ -1091,7 +1025,10 @@ def test_equality_maximize_efficiency(self, solver_name: str) -> None: power = m.add_variables(lower=0, upper=100, name="power") eff = m.add_variables(name="eff") m.add_piecewise_constraints( - piecewise(power, [0, 25, 50, 75, 100], [0.7, 0.85, 0.95, 0.9, 0.8]) == eff, + power, + eff, + [0, 25, 50, 75, 100], + [0.7, 0.85, 0.95, 0.9, 0.8], ) m.add_objective(eff, sense="max") status, _ = m.solve(solver_name=solver_name) @@ -1104,12 +1041,10 @@ def test_disjunctive_solve(self, solver_name: str) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise( - x, - segments([[0.0, 10.0], [50.0, 100.0]]), - segments([[0.0, 5.0], [20.0, 80.0]]), - ) - == y, + x, + y, + segments([[0.0, 10.0], [50.0, 100.0]]), + segments([[0.0, 5.0], [20.0, 80.0]]), ) m.add_constraints(x >= 60, name="x_min") m.add_objective(y) @@ -1138,7 +1073,11 @@ def test_concave_le(self, solver_name: str) -> None: y = m.add_variables(name="y") # Concave: [0,0],[50,40],[100,60] m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 40, 60]) >= y, + x, + y, + [0, 50, 100], + [0, 40, 60], + sign="<=", ) m.add_constraints(x <= 75, name="x_max") m.add_objective(y, sense="max") @@ -1155,7 +1094,11 @@ def test_convex_ge(self, solver_name: str) -> None: y = m.add_variables(name="y") # Convex: [0,0],[50,10],[100,60] m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 10, 60]) <= y, + x, + y, + [0, 50, 100], + [0, 10, 60], + sign=">=", ) m.add_constraints(x >= 25, name="x_min") m.add_objective(y) @@ -1172,7 +1115,11 @@ def test_slopes_equivalence(self, solver_name: str) -> None: x1 = m1.add_variables(lower=0, upper=100, name="x") y1 = m1.add_variables(name="y") m1.add_piecewise_constraints( - piecewise(x1, [0, 50, 100], [0, 40, 60]) >= y1, + x1, + y1, + [0, 50, 100], + [0, 40, 60], + sign="<=", ) m1.add_constraints(x1 <= 75, name="x_max") m1.add_objective(y1, sense="max") @@ -1183,12 +1130,11 @@ def test_slopes_equivalence(self, solver_name: str) -> None: x2 = m2.add_variables(lower=0, upper=100, name="x") y2 = m2.add_variables(name="y") m2.add_piecewise_constraints( - piecewise( - x2, - [0, 50, 100], - breakpoints(slopes=[0.8, 0.4], x_points=[0, 50, 100], y0=0), - ) - >= y2, + x2, + y2, + [0, 50, 100], + breakpoints(slopes=[0.8, 0.4], x_points=[0, 50, 100], y0=0), + sign="<=", ) m2.add_constraints(x2 <= 75, name="x_max") m2.add_objective(y2, sense="max") @@ -1209,9 +1155,13 @@ def test_lp_domain_constraints_created(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") - # Concave: slopes decreasing → y <= pw uses LP + # Concave: slopes decreasing -> y <= pw uses LP m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 40, 60]) >= y, + x, + y, + [0, 50, 100], + [0, 40, 60], + sign="<=", ) assert f"pwl0{PWL_LP_DOMAIN_SUFFIX}_lo" in m.constraints assert f"pwl0{PWL_LP_DOMAIN_SUFFIX}_hi" in m.constraints @@ -1224,7 +1174,11 @@ def test_lp_domain_constraints_multidim(self) -> None: x_pts = breakpoints({"a": [0, 50, 100], "b": [10, 60, 110]}, dim="entity") y_pts = breakpoints({"a": [0, 40, 60], "b": [5, 35, 55]}, dim="entity") m.add_piecewise_constraints( - piecewise(x, x_pts, y_pts) >= y, + x, + y, + x_pts, + y_pts, + sign="<=", ) lo_name = f"pwl0{PWL_LP_DOMAIN_SUFFIX}_lo" hi_name = f"pwl0{PWL_LP_DOMAIN_SUFFIX}_hi" @@ -1249,7 +1203,11 @@ def test_incremental_creates_active_bound(self) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - piecewise(x, [0, 10, 50, 100], [5, 2, 20, 80], active=u) == y, + x, + y, + [0, 10, 50, 100], + [5, 2, 20, 80], + active=u, method="incremental", ) assert f"pwl0{PWL_ACTIVE_BOUND_SUFFIX}" in m.constraints @@ -1261,7 +1219,10 @@ def test_active_none_is_default(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - piecewise(x, [0, 10, 50], [0, 5, 30]) == y, + x, + y, + [0, 10, 50], + [0, 5, 30], method="incremental", ) assert f"pwl0{PWL_ACTIVE_BOUND_SUFFIX}" not in m.constraints @@ -1273,19 +1234,29 @@ def test_active_with_lp_method_raises(self) -> None: u = m.add_variables(binary=True, name="u") with pytest.raises(ValueError, match="not supported with method='lp'"): m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 40, 60], active=u) >= y, + x, + y, + [0, 50, 100], + [0, 40, 60], + sign="<=", + active=u, method="lp", ) def test_active_with_auto_lp_raises(self) -> None: - """Auto selects LP for concave >=, but active is incompatible.""" + """Auto selects LP for concave <=, but active is incompatible.""" m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") with pytest.raises(ValueError, match="not supported with method='lp'"): m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 40, 60], active=u) >= y, + x, + y, + [0, 50, 100], + [0, 40, 60], + sign="<=", + active=u, ) def test_incremental_inequality_with_active(self) -> None: @@ -1295,7 +1266,12 @@ def test_incremental_inequality_with_active(self) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 10, 50], active=u) >= y, + x, + y, + [0, 50, 100], + [0, 10, 50], + sign="<=", + active=u, method="incremental", ) assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables @@ -1309,7 +1285,11 @@ def test_active_with_linear_expression(self) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 10, 50], active=1 * u) == y, + x, + y, + [0, 50, 100], + [0, 10, 50], + active=1 * u, method="incremental", ) assert f"pwl0{PWL_ACTIVE_BOUND_SUFFIX}" in m.constraints @@ -1333,7 +1313,11 @@ def test_incremental_active_on(self, solver_name: str) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 10, 50], active=u) == y, + x, + y, + [0, 50, 100], + [0, 10, 50], + active=u, method="incremental", ) m.add_constraints(u >= 1, name="force_on") @@ -1351,7 +1335,11 @@ def test_incremental_active_off(self, solver_name: str) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 10, 50], active=u) == y, + x, + y, + [0, 50, 100], + [0, 10, 50], + active=u, method="incremental", ) m.add_constraints(u <= 0, name="force_off") @@ -1363,9 +1351,9 @@ def test_incremental_active_off(self, solver_name: str) -> None: def test_incremental_nonzero_base_active_off(self, solver_name: str) -> None: """ - Non-zero base (x₀=20, y₀=5) with u=0 must still force zero. + Non-zero base (x0=20, y0=5) with u=0 must still force zero. - Tests the x₀*u / y₀*u base term multiplication — would fail if + Tests the x0*u / y0*u base term multiplication -- would fail if base terms aren't multiplied by active. """ m = Model() @@ -1373,7 +1361,11 @@ def test_incremental_nonzero_base_active_off(self, solver_name: str) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - piecewise(x, [20, 60, 100], [5, 20, 50], active=u) == y, + x, + y, + [20, 60, 100], + [5, 20, 50], + active=u, method="incremental", ) m.add_constraints(u <= 0, name="force_off") @@ -1390,7 +1382,12 @@ def test_incremental_inequality_active_off(self, solver_name: str) -> None: y = m.add_variables(lower=0, name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 10, 50], active=u) >= y, + x, + y, + [0, 50, 100], + [0, 10, 50], + sign="<=", + active=u, method="incremental", ) m.add_constraints(u <= 0, name="force_off") @@ -1410,8 +1407,11 @@ def test_unit_commitment_pattern(self, solver_name: str) -> None: u = m.add_variables(binary=True, name="commit") m.add_piecewise_constraints( - piecewise(power, [p_min, p_max], [fuel_at_pmin, fuel_at_pmax], active=u) - == fuel, + power, + fuel, + [p_min, p_max], + [fuel_at_pmin, fuel_at_pmax], + active=u, method="incremental", ) m.add_constraints(power >= 50, name="demand") @@ -1432,7 +1432,11 @@ def test_multi_dimensional_solver(self, solver_name: str) -> None: y = m.add_variables(coords=[gens], name="y") u = m.add_variables(binary=True, coords=[gens], name="u") m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 10, 50], active=u) == y, + x, + y, + [0, 50, 100], + [0, 10, 50], + active=u, method="incremental", ) m.add_constraints(u.sel(gen="a") >= 1, name="a_on") @@ -1454,13 +1458,17 @@ def solver_name(self, request: pytest.FixtureRequest) -> str: return request.param def test_sos2_active_off(self, solver_name: str) -> None: - """SOS2: u=0 forces Σλ=0, collapsing x=0, y=0.""" + """SOS2: u=0 forces sum(lambda)=0, collapsing x=0, y=0.""" m = Model() x = m.add_variables(lower=0, upper=100, name="x") y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - piecewise(x, [0, 50, 100], [0, 10, 50], active=u) == y, + x, + y, + [0, 50, 100], + [0, 10, 50], + active=u, method="sos2", ) m.add_constraints(u <= 0, name="force_off") @@ -1471,19 +1479,17 @@ def test_sos2_active_off(self, solver_name: str) -> None: np.testing.assert_allclose(float(y.solution.values), 0, atol=1e-4) def test_disjunctive_active_off(self, solver_name: str) -> None: - """Disjunctive: u=0 forces Σz_k=0, collapsing x=0, y=0.""" + """Disjunctive: u=0 forces sum(z_k)=0, collapsing x=0, y=0.""" m = Model() x = m.add_variables(lower=0, upper=100, name="x") y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - piecewise( - x, - segments([[0.0, 10.0], [50.0, 100.0]]), - segments([[0.0, 5.0], [20.0, 80.0]]), - active=u, - ) - == y, + x, + y, + segments([[0.0, 10.0], [50.0, 100.0]]), + segments([[0.0, 5.0], [20.0, 80.0]]), + active=u, ) m.add_constraints(u <= 0, name="force_off") m.add_objective(y, sense="max") @@ -1491,3 +1497,142 @@ def test_disjunctive_active_off(self, solver_name: str) -> None: assert status == "ok" np.testing.assert_allclose(float(x.solution.values), 0, atol=1e-4) np.testing.assert_allclose(float(y.solution.values), 0, atol=1e-4) + + +# =========================================================================== +# N-variable path +# =========================================================================== + + +class TestNVariable: + """Tests for the N-variable (dict-based) piecewise constraint API.""" + + def _make_chp_breakpoints(self) -> xr.DataArray: + """Create a 2-variable breakpoint array for a CHP-like problem.""" + return xr.DataArray( + [[0.0, 50.0, 100.0], [0.0, 20.0, 60.0]], + dims=["var", BREAKPOINT_DIM], + coords={"var": ["power", "fuel"], BREAKPOINT_DIM: [0, 1, 2]}, + ) + + def test_sos2_creates_lambda_and_link(self) -> None: + m = Model() + power = m.add_variables(lower=0, upper=100, name="power") + fuel = m.add_variables(name="fuel") + bp = self._make_chp_breakpoints() + m.add_piecewise_constraints( + exprs={"power": power, "fuel": fuel}, + breakpoints=bp, + method="sos2", + ) + assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables + assert f"pwl0{PWL_CONVEX_SUFFIX}" in m.constraints + assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints + + def test_incremental_creates_delta(self) -> None: + m = Model() + power = m.add_variables(lower=0, upper=100, name="power") + fuel = m.add_variables(name="fuel") + bp = self._make_chp_breakpoints() + m.add_piecewise_constraints( + exprs={"power": power, "fuel": fuel}, + breakpoints=bp, + method="incremental", + ) + assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables + assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints + + def test_auto_selects_method(self) -> None: + m = Model() + power = m.add_variables(lower=0, upper=100, name="power") + fuel = m.add_variables(name="fuel") + bp = self._make_chp_breakpoints() + m.add_piecewise_constraints( + exprs={"power": power, "fuel": fuel}, + breakpoints=bp, + ) + # Auto should select incremental for monotonic breakpoints + assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables + + def test_lp_method_raises(self) -> None: + m = Model() + power = m.add_variables(lower=0, upper=100, name="power") + fuel = m.add_variables(name="fuel") + bp = self._make_chp_breakpoints() + with pytest.raises(ValueError, match="not supported for N-variable"): + m.add_piecewise_constraints( + exprs={"power": power, "fuel": fuel}, + breakpoints=bp, + method="lp", + ) + + def test_missing_breakpoints_raises(self) -> None: + m = Model() + power = m.add_variables(name="power") + with pytest.raises(TypeError, match="both 'exprs' and 'breakpoints'"): + m.add_piecewise_constraints( + exprs={"power": power}, + ) + + def test_three_variables(self) -> None: + m = Model() + power = m.add_variables(lower=0, upper=100, name="power") + fuel = m.add_variables(name="fuel") + heat = m.add_variables(name="heat") + bp = xr.DataArray( + [[0.0, 50.0, 100.0], [0.0, 20.0, 60.0], [0.0, 30.0, 80.0]], + dims=["var", BREAKPOINT_DIM], + coords={"var": ["power", "fuel", "heat"], BREAKPOINT_DIM: [0, 1, 2]}, + ) + m.add_piecewise_constraints( + exprs={"power": power, "fuel": fuel, "heat": heat}, + breakpoints=bp, + method="sos2", + ) + assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables + assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints + # link constraint should have var dimension + link = m.constraints[f"pwl0{PWL_X_LINK_SUFFIX}"] + assert "var" in link.labels.dims + + def test_custom_name(self) -> None: + m = Model() + power = m.add_variables(lower=0, upper=100, name="power") + fuel = m.add_variables(name="fuel") + bp = self._make_chp_breakpoints() + m.add_piecewise_constraints( + exprs={"power": power, "fuel": fuel}, + breakpoints=bp, + name="chp", + ) + assert f"chp{PWL_DELTA_SUFFIX}" in m.variables + + def test_missing_breakpoint_dim_raises(self) -> None: + m = Model() + power = m.add_variables(name="power") + fuel = m.add_variables(name="fuel") + bp = xr.DataArray( + [[0.0, 50.0], [0.0, 20.0]], + dims=["var", "knot"], + coords={"var": ["power", "fuel"], "knot": [0, 1]}, + ) + with pytest.raises(ValueError, match="must have a"): + m.add_piecewise_constraints( + exprs={"power": power, "fuel": fuel}, + breakpoints=bp, + ) + + def test_link_dim_mismatch_raises(self) -> None: + m = Model() + power = m.add_variables(name="power") + fuel = m.add_variables(name="fuel") + bp = xr.DataArray( + [[0.0, 50.0], [0.0, 20.0]], + dims=["wrong", BREAKPOINT_DIM], + coords={"wrong": ["a", "b"], BREAKPOINT_DIM: [0, 1]}, + ) + with pytest.raises(ValueError, match="Could not auto-detect"): + m.add_piecewise_constraints( + exprs={"power": power, "fuel": fuel}, + breakpoints=bp, + ) From 0fab7fcac007c6ca20dde09558aa76020c594f0b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:14:05 +0200 Subject: [PATCH 02/30] refac: use keyword-only args for 2-variable piecewise API Change add_piecewise_constraints() to use keyword-only parameters (x=, y=, x_points=, y_points=) instead of positional args. Add detailed docstring documenting the mathematical meaning of equality vs inequality constraints. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/piecewise-linear-constraints.ipynb | 50 +- linopy/piecewise.py | 169 +++---- test/test_piecewise_constraints.py | 530 ++++++++++---------- 3 files changed, 374 insertions(+), 375 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index bddfe1c9..20a31d99 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| 6 | CHP plant (N-variable) | Joint power/fuel/heat | N-variable SOS2 |\n\n**API:** `m.add_piecewise_constraints(x, y, x_pts, y_pts, sign=\"==\")` for\ntwo-variable constraints, or `m.add_piecewise_constraints(exprs={...}, breakpoints=bp)`\nfor N-variable constraints." + "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| 6 | CHP plant (N-variable) | Joint power/fuel/heat | N-variable SOS2 |\n\n**API:**\n- **2-variable:** `m.add_piecewise_constraints(x=power, y=fuel, x_points=xp, y_points=yp)`\n- **N-variable:** `m.add_piecewise_constraints(exprs={...}, breakpoints=bp)`" }, { "cell_type": "code", @@ -144,13 +144,12 @@ "power = m1.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", "fuel = m1.add_variables(name=\"fuel\", lower=0, coords=[time])\n", "\n", - "# 2-variable API: x, y, x_points, y_points\n", "# breakpoints are auto-broadcast to match the time dimension\n", "m1.add_piecewise_constraints(\n", - " power,\n", - " fuel,\n", - " x_pts1,\n", - " y_pts1,\n", + " x=power,\n", + " y=fuel,\n", + " x_points=x_pts1,\n", + " y_points=y_pts1,\n", " name=\"pwl\",\n", " method=\"sos2\",\n", ")\n", @@ -281,12 +280,11 @@ "power = m2.add_variables(name=\"power\", lower=0, upper=150, coords=[time])\n", "fuel = m2.add_variables(name=\"fuel\", lower=0, coords=[time])\n", "\n", - "# breakpoints are auto-broadcast to match the time dimension\n", "m2.add_piecewise_constraints(\n", - " power,\n", - " fuel,\n", - " x_pts2,\n", - " y_pts2,\n", + " x=power,\n", + " y=fuel,\n", + " x_points=x_pts2,\n", + " y_points=y_pts2,\n", " name=\"pwl\",\n", " method=\"incremental\",\n", ")\n", @@ -425,12 +423,11 @@ "cost = m3.add_variables(name=\"cost\", lower=0, coords=[time])\n", "backup = m3.add_variables(name=\"backup\", lower=0, coords=[time])\n", "\n", - "# breakpoints are auto-broadcast to match the time dimension\n", "m3.add_piecewise_constraints(\n", - " power,\n", - " cost,\n", - " x_seg,\n", - " y_seg,\n", + " x=power,\n", + " y=cost,\n", + " x_points=x_seg,\n", + " y_points=y_seg,\n", " name=\"pwl\",\n", ")\n", "\n", @@ -525,12 +522,12 @@ "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", - "# fuel <= concave_function(power): sign=\"<=\" auto-selects LP method\n", + "# sign=\"<=\" means: fuel <= f(power) — y is bounded above by the piecewise function\n", "m4.add_piecewise_constraints(\n", - " power,\n", - " fuel,\n", - " x_pts4,\n", - " y_pts4,\n", + " x=power,\n", + " y=fuel,\n", + " x_points=x_pts4,\n", + " y_points=y_pts4,\n", " sign=\"<=\",\n", " name=\"pwl\",\n", ")\n", @@ -687,10 +684,10 @@ "# - commit=1: power in [30, 100], fuel = f(power)\n", "# - commit=0: power = 0, fuel = 0\n", "m6.add_piecewise_constraints(\n", - " power,\n", - " fuel,\n", - " x_pts6,\n", - " y_pts6,\n", + " x=power,\n", + " y=fuel,\n", + " x_points=x_pts6,\n", + " y_points=y_pts6,\n", " active=commit,\n", " name=\"pwl\",\n", " method=\"incremental\",\n", @@ -701,8 +698,7 @@ "backup = m6.add_variables(name=\"backup\", lower=0, coords=[time])\n", "m6.add_constraints(power + backup >= demand6, name=\"demand\")\n", "\n", - "# Objective: fuel + startup cost + backup at $5/MW (cheap enough that\n", - "# staying off at low demand beats committing at minimum load)\n", + "# Objective: fuel + startup cost + backup at $5/MW\n", "m6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())" ] }, diff --git a/linopy/piecewise.py b/linopy/piecewise.py index d0045f36..2103393a 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -9,7 +9,7 @@ from collections.abc import Mapping, Sequence from numbers import Real -from typing import TYPE_CHECKING, Literal, TypeAlias, overload +from typing import TYPE_CHECKING, Literal, TypeAlias import numpy as np import pandas as pd @@ -827,98 +827,99 @@ def _add_dpwl_sos2_core( # --------------------------------------------------------------------------- -@overload def add_piecewise_constraints( model: Model, - x: LinExprLike, - y: LinExprLike, - x_points: BreaksLike, - y_points: BreaksLike, *, - sign: str = "==", - method: Literal["sos2", "incremental", "auto", "lp"] = "auto", - active: LinExprLike | None = None, - name: str | None = None, - skip_nan_check: bool = False, -) -> Constraint: ... - - -@overload -def add_piecewise_constraints( - model: Model, - *, - exprs: Mapping[str, LinExprLike], - breakpoints: DataArray, - method: Literal["sos2", "incremental", "auto"] = "auto", - name: str | None = None, - mask: DataArray | None = None, - skip_nan_check: bool = False, -) -> Constraint: ... - - -def add_piecewise_constraints( - model: Model, - *args: LinExprLike | BreaksLike, - # 2-variable keyword args - sign: str = "==", - active: LinExprLike | None = None, - # N-variable keyword args exprs: Mapping[str, LinExprLike] | None = None, breakpoints: DataArray | None = None, + x: LinExprLike | None = None, + y: LinExprLike | None = None, + x_points: BreaksLike | None = None, + y_points: BreaksLike | None = None, + sign: str = "==", + active: LinExprLike | None = None, mask: DataArray | None = None, - # Shared keyword args method: Literal["sos2", "incremental", "auto", "lp"] = "auto", name: str | None = None, skip_nan_check: bool = False, - # Positional breakpoints for 2-variable case - x_points: BreaksLike | None = None, - y_points: BreaksLike | None = None, ) -> Constraint: - """ + r""" Add piecewise linear constraints. Supports two calling conventions: - **2-variable (positional):** + **N-variable — link N expressions through shared breakpoints:** + + All expressions are symmetric and linked via shared SOS2 lambda + (or incremental delta) weights. Mathematically, each expression is + constrained to lie on the interpolated breakpoint curve:: + + m.add_piecewise_constraints( + exprs={"power": power, "fuel": fuel, "heat": heat}, + breakpoints=bp, + ) + + **2-variable convenience — link x and y via separate breakpoints:** + + A shorthand that builds the N-variable dict internally. When + ``sign="=="`` (the default), the constraint is:: + + y = f(x) + + where *f* is the piecewise linear function defined by the breakpoints. + This is mathematically equivalent to the N-variable form with two + expressions. - Links two expressions ``x`` and ``y`` via separate x/y breakpoints:: + When ``sign`` is ``"<="`` or ``">="``, the constraint becomes an + *inequality*: - m.add_piecewise_constraints(x, y, x_points, y_points, sign="==") + - ``sign="<="`` means :math:`y \le f(x)` — *y* is bounded **above** + by the piecewise function. + - ``sign=">="`` means :math:`y \ge f(x)` — *y* is bounded **below** + by the piecewise function. - **N-variable (keyword):** + Inequality constraints introduce an auxiliary variable *z* that + satisfies the equality *z = f(x)*, then adds *y ≤ z* or *y ≥ z*. + This is a 2-variable-only feature because it requires distinct + "input" (*x*) and "output" (*y*) roles. - Links N expressions through shared breakpoints (a single DataArray - whose coordinates match the dict keys):: + Example:: m.add_piecewise_constraints( - exprs={"power": power, "fuel": fuel, "heat": heat}, - breakpoints=bp, + x=power, y=fuel, x_points=x_pts, y_points=y_pts, ) Parameters ---------- - model : Model - The linopy model. + exprs : dict of str to Variable/LinearExpression + Expressions to link (N-variable case). Keys must match a + dimension of ``breakpoints``. + breakpoints : DataArray + Shared breakpoint array (N-variable case). Must have a + breakpoint dimension and a linking dimension whose coordinates + match the ``exprs`` keys. x : Variable or LinearExpression - The "x" side expression (2-variable case). + The input expression (2-variable case). y : Variable or LinearExpression - The "y" side expression (2-variable case). + The output expression (2-variable case). x_points : BreaksLike Breakpoint x-coordinates (2-variable case). y_points : BreaksLike Breakpoint y-coordinates (2-variable case). - sign : str, default "==" - Constraint sign: "==", "<=", or ">=" (2-variable case). + sign : {"==", "<=", ">="}, default "==" + Constraint sign (2-variable case only). ``"=="`` constrains + *y = f(x)*. ``"<="`` constrains *y ≤ f(x)*. ``">="`` + constrains *y ≥ f(x)*. Ignored for the N-variable case + (always equality). active : Variable or LinearExpression, optional - Binary variable that scales the piecewise function (2-variable case). - exprs : dict of str to Variable/LinearExpression - Expressions to link (N-variable case). - breakpoints : DataArray - Shared breakpoint array (N-variable case). + Binary variable that gates the piecewise function. When + ``active=0``, all auxiliary variables (and thus *x* and *y*) + are forced to zero. 2-variable case only. mask : DataArray, optional Boolean mask for valid constraints. method : {"auto", "sos2", "incremental", "lp"}, default "auto" - Formulation method. "lp" is only available for the 2-variable case. + Formulation method. ``"lp"`` is only available for the + 2-variable inequality case. name : str, optional Base name for generated variables/constraints. skip_nan_check : bool, default False @@ -929,15 +930,15 @@ def add_piecewise_constraints( Constraint """ if exprs is not None: - # N-variable path + # ── N-variable path ────────────────────────────────────────── if breakpoints is None: raise TypeError( "N-variable call requires both 'exprs' and 'breakpoints' keywords." ) if method == "lp": raise ValueError( - "Pure LP method is not supported for N-variable piecewise constraints. " - "Use method='sos2' or method='incremental'." + "Pure LP method is not supported for N-variable piecewise " + "constraints. Use method='sos2' or method='incremental'." ) return _add_piecewise_nvar( model, @@ -948,36 +949,26 @@ def add_piecewise_constraints( mask=mask, skip_nan_check=skip_nan_check, ) - elif len(args) >= 2: - # 2-variable positional path: (x, y, x_points, y_points) - if len(args) == 4: - x_arg, y_arg, xp_arg, yp_arg = args - elif len(args) == 2 and x_points is not None and y_points is not None: - x_arg, y_arg = args - xp_arg, yp_arg = x_points, y_points - else: - raise TypeError( - "2-variable call requires 4 positional args: (x, y, x_points, y_points) " - "or 2 positional args with x_points= and y_points= keywords." - ) - return _add_piecewise_2var( - model, - x=x_arg, - y=y_arg, - x_points=xp_arg, - y_points=yp_arg, - sign=sign, - method=method, - active=active, - name=name, - skip_nan_check=skip_nan_check, - ) - else: + + # ── 2-variable convenience path ────────────────────────────────── + if x is None or y is None or x_points is None or y_points is None: raise TypeError( "add_piecewise_constraints() requires either:\n" - " - 2-variable: (x, y, x_points, y_points, sign=...)\n" - " - N-variable: (exprs={...}, breakpoints=...)" + " - N-variable: exprs={...}, breakpoints=...\n" + " - 2-variable: x=..., y=..., x_points=..., y_points=..." ) + return _add_piecewise_2var( + model, + x=x, + y=y, + x_points=x_points, + y_points=y_points, + sign=sign, + method=method, + active=active, + name=name, + skip_nan_check=skip_nan_check, + ) def _add_piecewise_2var( diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 15ab3aac..b60db7a3 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -286,10 +286,10 @@ def test_sos2(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - [0, 10, 50, 100], - [5, 2, 20, 80], + x=x, + y=y, + x_points=[0, 10, 50, 100], + y_points=[5, 2, 20, 80], method="sos2", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables @@ -304,10 +304,10 @@ def test_auto_selects_incremental_for_monotonic(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - [0, 10, 50, 100], - [5, 2, 20, 80], + x=x, + y=y, + x_points=[0, 10, 50, 100], + y_points=[5, 2, 20, 80], ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables assert f"pwl0{PWL_LAMBDA_SUFFIX}" not in m.variables @@ -317,10 +317,10 @@ def test_auto_nonmonotonic_falls_back_to_sos2(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - [0, 50, 30, 100], - [5, 20, 15, 80], + x=x, + y=y, + x_points=[0, 50, 30, 100], + y_points=[5, 20, 15, 80], ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables assert f"pwl0{PWL_DELTA_SUFFIX}" not in m.variables @@ -331,10 +331,14 @@ def test_multi_dimensional(self) -> None: x = m.add_variables(coords=[gens], name="x") y = m.add_variables(coords=[gens], name="y") m.add_piecewise_constraints( - x, - y, - breakpoints({"gen_a": [0, 10, 50], "gen_b": [0, 20, 80]}, dim="generator"), - breakpoints({"gen_a": [0, 5, 30], "gen_b": [0, 8, 50]}, dim="generator"), + x=x, + y=y, + x_points=breakpoints( + {"gen_a": [0, 10, 50], "gen_b": [0, 20, 80]}, dim="generator" + ), + y_points=breakpoints( + {"gen_a": [0, 5, 30], "gen_b": [0, 8, 50]}, dim="generator" + ), ) delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"] assert "generator" in delta.dims @@ -344,10 +348,12 @@ def test_with_slopes(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - [0, 10, 50, 100], - breakpoints(slopes=[-0.3, 0.45, 1.2], x_points=[0, 10, 50, 100], y0=5), + x=x, + y=y, + x_points=[0, 10, 50, 100], + y_points=breakpoints( + slopes=[-0.3, 0.45, 1.2], x_points=[0, 10, 50, 100], y0=5 + ), ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables @@ -366,10 +372,10 @@ def test_concave_le_uses_lp(self) -> None: # Concave: slopes 0.8, 0.4 (decreasing) # y <= pw -> sign="<=" m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 40, 60], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 40, 60], sign="<=", ) assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints @@ -383,10 +389,10 @@ def test_convex_le_uses_sos2_aux(self) -> None: y = m.add_variables(name="y") # Convex: slopes 0.2, 1.0 (increasing) m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 10, 60], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 10, 60], sign="<=", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables @@ -400,10 +406,10 @@ def test_convex_ge_uses_lp(self) -> None: # Convex: slopes 0.2, 1.0 (increasing) # y >= pw -> sign=">=" m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 10, 60], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 10, 60], sign=">=", ) assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints @@ -417,10 +423,10 @@ def test_concave_ge_uses_sos2_aux(self) -> None: y = m.add_variables(name="y") # Concave: slopes 0.8, 0.4 (decreasing) m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 40, 60], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 40, 60], sign=">=", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables @@ -432,10 +438,10 @@ def test_mixed_uses_sos2(self) -> None: y = m.add_variables(name="y") # Mixed: slopes 0.5, 0.3, 0.9 (down then up) m.add_piecewise_constraints( - x, - y, - [0, 30, 60, 100], - [0, 15, 24, 60], + x=x, + y=y, + x_points=[0, 30, 60, 100], + y_points=[0, 15, 24, 60], sign="<=", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables @@ -448,10 +454,10 @@ def test_method_lp_wrong_convexity_raises(self) -> None: # Convex function + y <= pw + method="lp" should fail with pytest.raises(ValueError, match="convex"): m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 10, 60], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 10, 60], sign="<=", method="lp", ) @@ -462,10 +468,10 @@ def test_method_lp_decreasing_breakpoints_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="strictly increasing x_points"): m.add_piecewise_constraints( - x, - y, - [100, 50, 0], - [60, 10, 0], + x=x, + y=y, + x_points=[100, 50, 0], + y_points=[60, 10, 0], sign=">=", method="lp", ) @@ -476,10 +482,10 @@ def test_auto_inequality_decreasing_breakpoints_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="strictly increasing x_points"): m.add_piecewise_constraints( - x, - y, - [100, 50, 0], - [60, 10, 0], + x=x, + y=y, + x_points=[100, 50, 0], + y_points=[60, 10, 0], sign=">=", ) @@ -489,10 +495,10 @@ def test_method_lp_equality_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="equality"): m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 40, 60], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 40, 60], method="lp", ) @@ -508,10 +514,10 @@ def test_creates_delta_vars(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - [0, 10, 50, 100], - [5, 2, 20, 80], + x=x, + y=y, + x_points=[0, 10, 50, 100], + y_points=[5, 2, 20, 80], method="incremental", ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables @@ -526,10 +532,10 @@ def test_nonmonotonic_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="strictly monotonic"): m.add_piecewise_constraints( - x, - y, - [0, 50, 30, 100], - [5, 20, 15, 80], + x=x, + y=y, + x_points=[0, 50, 30, 100], + y_points=[5, 20, 15, 80], method="incremental", ) @@ -538,10 +544,10 @@ def test_sos2_nonmonotonic_succeeds(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - [0, 50, 30, 100], - [5, 20, 15, 80], + x=x, + y=y, + x_points=[0, 50, 30, 100], + y_points=[5, 20, 15, 80], method="sos2", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables @@ -552,10 +558,10 @@ def test_two_breakpoints_no_fill(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - [0, 100], - [5, 80], + x=x, + y=y, + x_points=[0, 100], + y_points=[5, 80], method="incremental", ) delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"] @@ -568,10 +574,10 @@ def test_creates_binary_indicator_vars(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - [0, 10, 50, 100], - [5, 2, 20, 80], + x=x, + y=y, + x_points=[0, 10, 50, 100], + y_points=[5, 2, 20, 80], method="incremental", ) assert f"pwl0{PWL_INC_BINARY_SUFFIX}" in m.variables @@ -584,10 +590,10 @@ def test_creates_order_constraints(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - [0, 10, 50, 100], - [5, 2, 20, 80], + x=x, + y=y, + x_points=[0, 10, 50, 100], + y_points=[5, 2, 20, 80], method="incremental", ) assert f"pwl0{PWL_INC_ORDER_SUFFIX}" in m.constraints @@ -598,10 +604,10 @@ def test_two_breakpoints_no_order_constraint(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - [0, 100], - [5, 80], + x=x, + y=y, + x_points=[0, 100], + y_points=[5, 80], method="incremental", ) assert f"pwl0{PWL_INC_BINARY_SUFFIX}" in m.variables @@ -613,10 +619,10 @@ def test_decreasing_monotonic(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - [100, 50, 10, 0], - [80, 20, 2, 5], + x=x, + y=y, + x_points=[100, 50, 10, 0], + y_points=[80, 20, 2, 5], method="incremental", ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables @@ -633,10 +639,10 @@ def test_equality_creates_binary(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - segments([[0, 10], [50, 100]]), - segments([[0, 5], [20, 80]]), + x=x, + y=y, + x_points=segments([[0, 10], [50, 100]]), + y_points=segments([[0, 5], [20, 80]]), ) assert f"pwl0{PWL_BINARY_SUFFIX}" in m.variables assert f"pwl0{PWL_SELECT_SUFFIX}" in m.constraints @@ -650,10 +656,10 @@ def test_inequality_creates_aux(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - segments([[0, 10], [50, 100]]), - segments([[0, 5], [20, 80]]), + x=x, + y=y, + x_points=segments([[0, 10], [50, 100]]), + y_points=segments([[0, 5], [20, 80]]), sign="<=", ) assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables @@ -666,10 +672,10 @@ def test_method_lp_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="disjunctive"): m.add_piecewise_constraints( - x, - y, - segments([[0, 10], [50, 100]]), - segments([[0, 5], [20, 80]]), + x=x, + y=y, + x_points=segments([[0, 10], [50, 100]]), + y_points=segments([[0, 5], [20, 80]]), sign="<=", method="lp", ) @@ -680,10 +686,10 @@ def test_method_incremental_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="disjunctive"): m.add_piecewise_constraints( - x, - y, - segments([[0, 10], [50, 100]]), - segments([[0, 5], [20, 80]]), + x=x, + y=y, + x_points=segments([[0, 10], [50, 100]]), + y_points=segments([[0, 5], [20, 80]]), method="incremental", ) @@ -693,13 +699,13 @@ def test_multi_dimensional(self) -> None: x = m.add_variables(coords=[gens], name="x") y = m.add_variables(coords=[gens], name="y") m.add_piecewise_constraints( - x, - y, - segments( + x=x, + y=y, + x_points=segments( {"gen_a": [[0, 10], [50, 100]], "gen_b": [[0, 20], [60, 90]]}, dim="generator", ), - segments( + y_points=segments( {"gen_a": [[0, 5], [20, 80]], "gen_b": [[0, 8], [30, 70]]}, dim="generator", ), @@ -720,7 +726,7 @@ def test_wrong_arg_types_raises(self) -> None: m = Model() x = m.add_variables(name="x") with pytest.raises(TypeError, match="requires either"): - m.add_piecewise_constraints(x) # type: ignore + m.add_piecewise_constraints(x=x) # type: ignore def test_invalid_method_raises(self) -> None: m = Model() @@ -728,10 +734,10 @@ def test_invalid_method_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="method must be"): m.add_piecewise_constraints( - x, - y, - [0, 10, 50], - [5, 2, 20], + x=x, + y=y, + x_points=[0, 10, 50], + y_points=[5, 2, 20], method="invalid", # type: ignore ) @@ -747,8 +753,10 @@ def test_auto_name(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") z = m.add_variables(name="z") - m.add_piecewise_constraints(x, y, [0, 10, 50], [5, 2, 20]) - m.add_piecewise_constraints(x, z, [0, 20, 80], [10, 15, 50]) + m.add_piecewise_constraints(x=x, y=y, x_points=[0, 10, 50], y_points=[5, 2, 20]) + m.add_piecewise_constraints( + x=x, y=z, x_points=[0, 20, 80], y_points=[10, 15, 50] + ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables assert f"pwl1{PWL_DELTA_SUFFIX}" in m.variables @@ -757,10 +765,10 @@ def test_custom_name(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - [0, 10, 50], - [5, 2, 20], + x=x, + y=y, + x_points=[0, 10, 50], + y_points=[5, 2, 20], name="my_pwl", ) assert f"my_pwl{PWL_DELTA_SUFFIX}" in m.variables @@ -782,10 +790,14 @@ def test_broadcast_over_extra_dims(self) -> None: y = m.add_variables(coords=[gens, times], name="y") # Points only have generator dim -> broadcast over time m.add_piecewise_constraints( - x, - y, - breakpoints({"gen_a": [0, 10, 50], "gen_b": [0, 20, 80]}, dim="generator"), - breakpoints({"gen_a": [0, 5, 30], "gen_b": [0, 8, 50]}, dim="generator"), + x=x, + y=y, + x_points=breakpoints( + {"gen_a": [0, 10, 50], "gen_b": [0, 20, 80]}, dim="generator" + ), + y_points=breakpoints( + {"gen_a": [0, 5, 30], "gen_b": [0, 8, 50]}, dim="generator" + ), ) delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"] assert "generator" in delta.dims @@ -806,10 +818,10 @@ def test_nan_masks_lambda_labels(self) -> None: x_pts = xr.DataArray([0, 10, 50, np.nan], dims=[BREAKPOINT_DIM]) y_pts = xr.DataArray([0, 5, 20, np.nan], dims=[BREAKPOINT_DIM]) m.add_piecewise_constraints( - x, - y, - x_pts, - y_pts, + x=x, + y=y, + x_points=x_pts, + y_points=y_pts, method="sos2", ) lam = m.variables[f"pwl0{PWL_LAMBDA_SUFFIX}"] @@ -826,10 +838,10 @@ def test_skip_nan_check_with_nan_raises(self) -> None: y_pts = xr.DataArray([0, 5, 20, np.nan], dims=[BREAKPOINT_DIM]) with pytest.raises(ValueError, match="skip_nan_check=True but breakpoints"): m.add_piecewise_constraints( - x, - y, - x_pts, - y_pts, + x=x, + y=y, + x_points=x_pts, + y_points=y_pts, method="sos2", skip_nan_check=True, ) @@ -842,10 +854,10 @@ def test_skip_nan_check_without_nan(self) -> None: x_pts = xr.DataArray([0, 10, 50, 100], dims=[BREAKPOINT_DIM]) y_pts = xr.DataArray([0, 5, 20, 40], dims=[BREAKPOINT_DIM]) m.add_piecewise_constraints( - x, - y, - x_pts, - y_pts, + x=x, + y=y, + x_points=x_pts, + y_points=y_pts, method="sos2", skip_nan_check=True, ) @@ -861,10 +873,10 @@ def test_sos2_interior_nan_raises(self) -> None: y_pts = xr.DataArray([0, np.nan, 20, 40], dims=[BREAKPOINT_DIM]) with pytest.raises(ValueError, match="non-trailing NaN"): m.add_piecewise_constraints( - x, - y, - x_pts, - y_pts, + x=x, + y=y, + x_points=x_pts, + y_points=y_pts, method="sos2", ) @@ -883,19 +895,19 @@ def test_linear_uses_lp_both_directions(self) -> None: y2 = m.add_variables(name="y2") # y1 >= f(x) -> LP m.add_piecewise_constraints( - x, - y1, - [0, 50, 100], - [0, 25, 50], + x=x, + y=y1, + x_points=[0, 50, 100], + y_points=[0, 25, 50], sign=">=", ) assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints # y2 <= f(x) -> also LP (linear is both convex and concave) m.add_piecewise_constraints( - x, - y2, - [0, 50, 100], - [0, 25, 50], + x=x, + y=y2, + x_points=[0, 50, 100], + y_points=[0, 25, 50], sign="<=", ) assert f"pwl1{PWL_LP_SUFFIX}" in m.constraints @@ -906,10 +918,10 @@ def test_single_segment_uses_lp(self) -> None: x = m.add_variables(lower=0, upper=100, name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - [0, 100], - [0, 50], + x=x, + y=y, + x_points=[0, 100], + y_points=[0, 50], sign=">=", ) assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints @@ -922,10 +934,10 @@ def test_mixed_convexity_uses_sos2(self) -> None: # Mixed: slope goes up then down -> neither convex nor concave # y <= f(x) -> sign="<=" m.add_piecewise_constraints( - x, - y, - [0, 30, 60, 100], - [0, 40, 30, 50], + x=x, + y=y, + x_points=[0, 30, 60, 100], + y_points=[0, 40, 30, 50], sign="<=", ) assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables @@ -943,10 +955,10 @@ def test_sos2_equality(self, tmp_path: Path) -> None: x = m.add_variables(name="x", lower=0, upper=100) y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - [0.0, 10.0, 50.0, 100.0], - [5.0, 2.0, 20.0, 80.0], + x=x, + y=y, + x_points=[0.0, 10.0, 50.0, 100.0], + y_points=[5.0, 2.0, 20.0, 80.0], method="sos2", ) m.add_objective(y) @@ -962,10 +974,10 @@ def test_lp_formulation_no_sos2(self, tmp_path: Path) -> None: y = m.add_variables(name="y") # Concave: y <= pw uses LP m.add_piecewise_constraints( - x, - y, - [0.0, 50.0, 100.0], - [0.0, 40.0, 60.0], + x=x, + y=y, + x_points=[0.0, 50.0, 100.0], + y_points=[0.0, 40.0, 60.0], sign="<=", ) m.add_objective(y) @@ -979,10 +991,10 @@ def test_disjunctive_sos2_and_binary(self, tmp_path: Path) -> None: x = m.add_variables(name="x", lower=0, upper=100) y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - segments([[0.0, 10.0], [50.0, 100.0]]), - segments([[0.0, 5.0], [20.0, 80.0]]), + x=x, + y=y, + x_points=segments([[0.0, 10.0], [50.0, 100.0]]), + y_points=segments([[0.0, 5.0], [20.0, 80.0]]), ) m.add_objective(y) fn = tmp_path / "pwl_disj.lp" @@ -1008,10 +1020,10 @@ def test_equality_minimize_cost(self, solver_name: str) -> None: x = m.add_variables(lower=0, upper=100, name="x") cost = m.add_variables(name="cost") m.add_piecewise_constraints( - x, - cost, - [0, 50, 100], - [0, 10, 50], + x=x, + y=cost, + x_points=[0, 50, 100], + y_points=[0, 10, 50], ) m.add_constraints(x >= 50, name="x_min") m.add_objective(cost) @@ -1025,10 +1037,10 @@ def test_equality_maximize_efficiency(self, solver_name: str) -> None: power = m.add_variables(lower=0, upper=100, name="power") eff = m.add_variables(name="eff") m.add_piecewise_constraints( - power, - eff, - [0, 25, 50, 75, 100], - [0.7, 0.85, 0.95, 0.9, 0.8], + x=power, + y=eff, + x_points=[0, 25, 50, 75, 100], + y_points=[0.7, 0.85, 0.95, 0.9, 0.8], ) m.add_objective(eff, sense="max") status, _ = m.solve(solver_name=solver_name) @@ -1041,10 +1053,10 @@ def test_disjunctive_solve(self, solver_name: str) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - segments([[0.0, 10.0], [50.0, 100.0]]), - segments([[0.0, 5.0], [20.0, 80.0]]), + x=x, + y=y, + x_points=segments([[0.0, 10.0], [50.0, 100.0]]), + y_points=segments([[0.0, 5.0], [20.0, 80.0]]), ) m.add_constraints(x >= 60, name="x_min") m.add_objective(y) @@ -1073,10 +1085,10 @@ def test_concave_le(self, solver_name: str) -> None: y = m.add_variables(name="y") # Concave: [0,0],[50,40],[100,60] m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 40, 60], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 40, 60], sign="<=", ) m.add_constraints(x <= 75, name="x_max") @@ -1094,10 +1106,10 @@ def test_convex_ge(self, solver_name: str) -> None: y = m.add_variables(name="y") # Convex: [0,0],[50,10],[100,60] m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 10, 60], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 10, 60], sign=">=", ) m.add_constraints(x >= 25, name="x_min") @@ -1115,10 +1127,10 @@ def test_slopes_equivalence(self, solver_name: str) -> None: x1 = m1.add_variables(lower=0, upper=100, name="x") y1 = m1.add_variables(name="y") m1.add_piecewise_constraints( - x1, - y1, - [0, 50, 100], - [0, 40, 60], + x=x1, + y=y1, + x_points=[0, 50, 100], + y_points=[0, 40, 60], sign="<=", ) m1.add_constraints(x1 <= 75, name="x_max") @@ -1130,10 +1142,10 @@ def test_slopes_equivalence(self, solver_name: str) -> None: x2 = m2.add_variables(lower=0, upper=100, name="x") y2 = m2.add_variables(name="y") m2.add_piecewise_constraints( - x2, - y2, - [0, 50, 100], - breakpoints(slopes=[0.8, 0.4], x_points=[0, 50, 100], y0=0), + x=x2, + y=y2, + x_points=[0, 50, 100], + y_points=breakpoints(slopes=[0.8, 0.4], x_points=[0, 50, 100], y0=0), sign="<=", ) m2.add_constraints(x2 <= 75, name="x_max") @@ -1157,10 +1169,10 @@ def test_lp_domain_constraints_created(self) -> None: y = m.add_variables(name="y") # Concave: slopes decreasing -> y <= pw uses LP m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 40, 60], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 40, 60], sign="<=", ) assert f"pwl0{PWL_LP_DOMAIN_SUFFIX}_lo" in m.constraints @@ -1174,10 +1186,10 @@ def test_lp_domain_constraints_multidim(self) -> None: x_pts = breakpoints({"a": [0, 50, 100], "b": [10, 60, 110]}, dim="entity") y_pts = breakpoints({"a": [0, 40, 60], "b": [5, 35, 55]}, dim="entity") m.add_piecewise_constraints( - x, - y, - x_pts, - y_pts, + x=x, + y=y, + x_points=x_pts, + y_points=y_pts, sign="<=", ) lo_name = f"pwl0{PWL_LP_DOMAIN_SUFFIX}_lo" @@ -1203,10 +1215,10 @@ def test_incremental_creates_active_bound(self) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x, - y, - [0, 10, 50, 100], - [5, 2, 20, 80], + x=x, + y=y, + x_points=[0, 10, 50, 100], + y_points=[5, 2, 20, 80], active=u, method="incremental", ) @@ -1219,10 +1231,10 @@ def test_active_none_is_default(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x, - y, - [0, 10, 50], - [0, 5, 30], + x=x, + y=y, + x_points=[0, 10, 50], + y_points=[0, 5, 30], method="incremental", ) assert f"pwl0{PWL_ACTIVE_BOUND_SUFFIX}" not in m.constraints @@ -1234,10 +1246,10 @@ def test_active_with_lp_method_raises(self) -> None: u = m.add_variables(binary=True, name="u") with pytest.raises(ValueError, match="not supported with method='lp'"): m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 40, 60], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 40, 60], sign="<=", active=u, method="lp", @@ -1251,10 +1263,10 @@ def test_active_with_auto_lp_raises(self) -> None: u = m.add_variables(binary=True, name="u") with pytest.raises(ValueError, match="not supported with method='lp'"): m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 40, 60], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 40, 60], sign="<=", active=u, ) @@ -1266,10 +1278,10 @@ def test_incremental_inequality_with_active(self) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 10, 50], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 10, 50], sign="<=", active=u, method="incremental", @@ -1285,10 +1297,10 @@ def test_active_with_linear_expression(self) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 10, 50], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 10, 50], active=1 * u, method="incremental", ) @@ -1313,10 +1325,10 @@ def test_incremental_active_on(self, solver_name: str) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 10, 50], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 10, 50], active=u, method="incremental", ) @@ -1335,10 +1347,10 @@ def test_incremental_active_off(self, solver_name: str) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 10, 50], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 10, 50], active=u, method="incremental", ) @@ -1361,10 +1373,10 @@ def test_incremental_nonzero_base_active_off(self, solver_name: str) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x, - y, - [20, 60, 100], - [5, 20, 50], + x=x, + y=y, + x_points=[20, 60, 100], + y_points=[5, 20, 50], active=u, method="incremental", ) @@ -1382,10 +1394,10 @@ def test_incremental_inequality_active_off(self, solver_name: str) -> None: y = m.add_variables(lower=0, name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 10, 50], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 10, 50], sign="<=", active=u, method="incremental", @@ -1407,10 +1419,10 @@ def test_unit_commitment_pattern(self, solver_name: str) -> None: u = m.add_variables(binary=True, name="commit") m.add_piecewise_constraints( - power, - fuel, - [p_min, p_max], - [fuel_at_pmin, fuel_at_pmax], + x=power, + y=fuel, + x_points=[p_min, p_max], + y_points=[fuel_at_pmin, fuel_at_pmax], active=u, method="incremental", ) @@ -1432,10 +1444,10 @@ def test_multi_dimensional_solver(self, solver_name: str) -> None: y = m.add_variables(coords=[gens], name="y") u = m.add_variables(binary=True, coords=[gens], name="u") m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 10, 50], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 10, 50], active=u, method="incremental", ) @@ -1464,10 +1476,10 @@ def test_sos2_active_off(self, solver_name: str) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x, - y, - [0, 50, 100], - [0, 10, 50], + x=x, + y=y, + x_points=[0, 50, 100], + y_points=[0, 10, 50], active=u, method="sos2", ) @@ -1485,10 +1497,10 @@ def test_disjunctive_active_off(self, solver_name: str) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x, - y, - segments([[0.0, 10.0], [50.0, 100.0]]), - segments([[0.0, 5.0], [20.0, 80.0]]), + x=x, + y=y, + x_points=segments([[0.0, 10.0], [50.0, 100.0]]), + y_points=segments([[0.0, 5.0], [20.0, 80.0]]), active=u, ) m.add_constraints(u <= 0, name="force_off") From 22d21960137d86c733fb25b44e413fd1ee7ab186 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:28:07 +0200 Subject: [PATCH 03/30] docs: use breakpoints() in CHP example and add plot Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/piecewise-linear-constraints.ipynb | 197 +++++++++++++------- 1 file changed, 132 insertions(+), 65 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 20a31d99..a12078ef 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -10,8 +10,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:27.800436Z", - "start_time": "2026-03-09T10:17:27.796927Z" + "end_time": "2026-04-01T07:27:32.328993Z", + "start_time": "2026-04-01T07:27:32.323244Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.167007Z", @@ -102,8 +102,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:27.808870Z", - "start_time": "2026-03-09T10:17:27.806626Z" + "end_time": "2026-04-01T07:27:32.345982Z", + "start_time": "2026-04-01T07:27:32.342753Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.185693Z", @@ -126,8 +126,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:27.851223Z", - "start_time": "2026-03-09T10:17:27.811464Z" + "end_time": "2026-04-01T07:27:32.397039Z", + "start_time": "2026-04-01T07:27:32.353962Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.200170Z", @@ -164,8 +164,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:27.899254Z", - "start_time": "2026-03-09T10:17:27.854515Z" + "end_time": "2026-04-01T07:27:32.442855Z", + "start_time": "2026-04-01T07:27:32.401364Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.267522Z", @@ -185,8 +185,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:27.914316Z", - "start_time": "2026-03-09T10:17:27.909570Z" + "end_time": "2026-04-01T07:27:32.466547Z", + "start_time": "2026-04-01T07:27:32.460144Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.327139Z", @@ -206,8 +206,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.025921Z", - "start_time": "2026-03-09T10:17:27.922945Z" + "end_time": "2026-04-01T07:27:32.579749Z", + "start_time": "2026-04-01T07:27:32.472505Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.339689Z", @@ -238,8 +238,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.039245Z", - "start_time": "2026-03-09T10:17:28.035712Z" + "end_time": "2026-04-01T07:27:32.589529Z", + "start_time": "2026-04-01T07:27:32.586129Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.490092Z", @@ -262,8 +262,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.121499Z", - "start_time": "2026-03-09T10:17:28.052395Z" + "end_time": "2026-04-01T07:27:32.664822Z", + "start_time": "2026-04-01T07:27:32.597724Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.501317Z", @@ -299,8 +299,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.174903Z", - "start_time": "2026-03-09T10:17:28.124418Z" + "end_time": "2026-04-01T07:27:32.721419Z", + "start_time": "2026-04-01T07:27:32.668595Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.604434Z", @@ -320,8 +320,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.182912Z", - "start_time": "2026-03-09T10:17:28.178226Z" + "end_time": "2026-04-01T07:27:32.733739Z", + "start_time": "2026-04-01T07:27:32.727737Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.681833Z", @@ -341,8 +341,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.285938Z", - "start_time": "2026-03-09T10:17:28.191498Z" + "end_time": "2026-04-01T07:27:32.830743Z", + "start_time": "2026-04-01T07:27:32.743076Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.699350Z", @@ -378,8 +378,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.301657Z", - "start_time": "2026-03-09T10:17:28.294924Z" + "end_time": "2026-04-01T07:27:32.839177Z", + "start_time": "2026-04-01T07:27:32.835378Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.852397Z", @@ -404,8 +404,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.381180Z", - "start_time": "2026-03-09T10:17:28.308026Z" + "end_time": "2026-04-01T07:27:32.907702Z", + "start_time": "2026-04-01T07:27:32.845651Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.866940Z", @@ -441,8 +441,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.437326Z", - "start_time": "2026-03-09T10:17:28.384629Z" + "end_time": "2026-04-01T07:27:32.982947Z", + "start_time": "2026-04-01T07:27:32.916103Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.955750Z", @@ -462,8 +462,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.449248Z", - "start_time": "2026-03-09T10:17:28.444065Z" + "end_time": "2026-04-01T07:27:33.000867Z", + "start_time": "2026-04-01T07:27:32.993009Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.028114Z", @@ -500,8 +500,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.503165Z", - "start_time": "2026-03-09T10:17:28.458328Z" + "end_time": "2026-04-01T07:27:33.066507Z", + "start_time": "2026-04-01T07:27:33.015928Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.043492Z", @@ -543,8 +543,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.554560Z", - "start_time": "2026-03-09T10:17:28.520243Z" + "end_time": "2026-04-01T07:27:33.114652Z", + "start_time": "2026-04-01T07:27:33.070973Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.113818Z", @@ -564,8 +564,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.563539Z", - "start_time": "2026-03-09T10:17:28.559654Z" + "end_time": "2026-04-01T07:27:33.125893Z", + "start_time": "2026-04-01T07:27:33.121227Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.172009Z", @@ -585,8 +585,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.665419Z", - "start_time": "2026-03-09T10:17:28.575163Z" + "end_time": "2026-04-01T07:27:33.249644Z", + "start_time": "2026-04-01T07:27:33.133166Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.192604Z", @@ -617,8 +617,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.673673Z", - "start_time": "2026-03-09T10:17:28.668792Z" + "end_time": "2026-04-01T07:27:33.258656Z", + "start_time": "2026-04-01T07:27:33.254569Z" }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.345523Z", @@ -646,8 +646,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.685034Z", - "start_time": "2026-03-09T10:17:28.681601Z" + "end_time": "2026-04-01T07:27:33.269756Z", + "start_time": "2026-04-01T07:27:33.266342Z" } }, "outputs": [], @@ -668,8 +668,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.787328Z", - "start_time": "2026-03-09T10:17:28.697214Z" + "end_time": "2026-04-01T07:27:33.386556Z", + "start_time": "2026-04-01T07:27:33.277128Z" } }, "outputs": [], @@ -707,8 +707,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:28.878112Z", - "start_time": "2026-03-09T10:17:28.791383Z" + "end_time": "2026-04-01T07:27:33.460332Z", + "start_time": "2026-04-01T07:27:33.391322Z" } }, "outputs": [], @@ -721,8 +721,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:29.079925Z", - "start_time": "2026-03-09T10:17:29.069821Z" + "end_time": "2026-04-01T07:27:33.476944Z", + "start_time": "2026-04-01T07:27:33.469186Z" } }, "outputs": [], @@ -735,8 +735,8 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-03-09T10:17:29.226034Z", - "start_time": "2026-03-09T10:17:29.097467Z" + "end_time": "2026-04-01T07:27:33.596866Z", + "start_time": "2026-04-01T07:27:33.483794Z" } }, "outputs": [], @@ -757,21 +757,22 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:27:33.605404Z", + "start_time": "2026-04-01T07:27:33.601106Z" + } + }, "outputs": [], "source": [ - "from linopy.constants import BREAKPOINT_DIM\n", - "\n", "# CHP operating points: as load increases, power, fuel, and heat all change\n", - "# breakpoints array has a \"var\" dimension matching the expression dict keys\n", - "bp_chp = xr.DataArray(\n", - " [\n", - " [0.0, 30.0, 60.0, 100.0], # power [MW]\n", - " [0.0, 40.0, 85.0, 160.0], # fuel [MMBTU/h]\n", - " [0.0, 25.0, 55.0, 95.0],\n", - " ], # heat [MW_th]\n", - " dims=[\"var\", BREAKPOINT_DIM],\n", - " coords={\"var\": [\"power\", \"fuel\", \"heat\"], BREAKPOINT_DIM: [0, 1, 2, 3]},\n", + "bp_chp = linopy.breakpoints(\n", + " {\n", + " \"power\": [0, 30, 60, 100],\n", + " \"fuel\": [0, 40, 85, 160],\n", + " \"heat\": [0, 25, 55, 95],\n", + " },\n", + " dim=\"var\",\n", ")\n", "print(\"CHP breakpoints:\")\n", "print(bp_chp.to_pandas())" @@ -780,7 +781,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:27:33.702569Z", + "start_time": "2026-04-01T07:27:33.615111Z" + } + }, "outputs": [], "source": [ "m7 = linopy.Model()\n", @@ -805,7 +811,12 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:27:33.751671Z", + "start_time": "2026-04-01T07:27:33.706974Z" + } + }, "outputs": [], "source": [ "m7.solve(reformulate_sos=\"auto\")" @@ -814,11 +825,67 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:27:33.772537Z", + "start_time": "2026-04-01T07:27:33.765173Z" + } + }, "outputs": [], "source": [ "m7.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas()" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:27:33.883112Z", + "start_time": "2026-04-01T07:27:33.777156Z" + } + }, + "outputs": [], + "source": [ + "sol = m7.solution\n", + "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3.5))\n", + "\n", + "# Left: breakpoint curves with operating points\n", + "bp_power = bp_chp.sel(var=\"power\").values\n", + "bp_fuel = bp_chp.sel(var=\"fuel\").values\n", + "bp_heat = bp_chp.sel(var=\"heat\").values\n", + "\n", + "ax1.plot(bp_power, bp_fuel, \"o-\", color=\"C0\", label=\"Fuel (breakpoints)\")\n", + "ax1.plot(bp_power, bp_heat, \"s--\", color=\"C1\", label=\"Heat (breakpoints)\")\n", + "for t in time:\n", + " p = float(sol[\"power\"].sel(time=t))\n", + " ax1.plot(p, float(sol[\"fuel\"].sel(time=t)), \"D\", color=\"C0\", ms=10)\n", + " ax1.plot(p, float(sol[\"heat\"].sel(time=t)), \"D\", color=\"C1\", ms=10)\n", + "ax1.set(xlabel=\"Power [MW]\", ylabel=\"Fuel / Heat\", title=\"CHP operating curve\")\n", + "ax1.legend()\n", + "\n", + "# Right: stacked dispatch\n", + "x = list(range(len(time)))\n", + "ax2.bar(x, sol[\"power\"].values, color=\"C0\", label=\"Power\")\n", + "ax2.bar(x, sol[\"heat\"].values, bottom=sol[\"power\"].values, color=\"C1\", label=\"Heat\")\n", + "ax2.bar(\n", + " x,\n", + " sol[\"fuel\"].values,\n", + " bottom=sol[\"power\"].values + sol[\"heat\"].values,\n", + " color=\"C2\",\n", + " alpha=0.5,\n", + " label=\"Fuel\",\n", + ")\n", + "ax2.set(\n", + " xlabel=\"Time\",\n", + " ylabel=\"Value\",\n", + " title=\"CHP dispatch\",\n", + " xticks=x,\n", + " xticklabels=time.values,\n", + ")\n", + "ax2.legend()\n", + "plt.tight_layout()" + ] } ], "metadata": { From d0a01424f9c62dd44e92f11b1fb83ba6905a745d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:33:16 +0200 Subject: [PATCH 04/30] fix: broadcast N-variable breakpoints over expression dims The N-variable path was not broadcasting breakpoints to cover extra dimensions from the expressions (e.g. time), resulting in shared lambda variables across timesteps. Also simplify CHP example to use breakpoints() factory and add plot. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/piecewise-linear-constraints.ipynb | 466 +++++++++++--------- linopy/piecewise.py | 5 + 2 files changed, 262 insertions(+), 209 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index a12078ef..9db3bf96 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -7,21 +7,19 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:32.328993Z", - "start_time": "2026-04-01T07:27:32.323244Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.167007Z", "iopub.status.busy": "2026-03-06T11:51:29.166576Z", "iopub.status.idle": "2026-03-06T11:51:29.185103Z", "shell.execute_reply": "2026-03-06T11:51:29.184712Z", "shell.execute_reply.started": "2026-03-06T11:51:29.166974Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:12.466618Z", + "start_time": "2026-04-01T07:32:11.729309Z" } }, - "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "import pandas as pd\n", @@ -84,7 +82,9 @@ " )\n", " ax2.legend()\n", " plt.tight_layout()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -99,45 +99,43 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:32.345982Z", - "start_time": "2026-04-01T07:27:32.342753Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.185693Z", "iopub.status.busy": "2026-03-06T11:51:29.185601Z", "iopub.status.idle": "2026-03-06T11:51:29.199760Z", "shell.execute_reply": "2026-03-06T11:51:29.199416Z", "shell.execute_reply.started": "2026-03-06T11:51:29.185683Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:12.501563Z", + "start_time": "2026-04-01T07:32:12.469248Z" } }, - "outputs": [], "source": [ "x_pts1 = linopy.breakpoints([0, 30, 60, 100])\n", "y_pts1 = linopy.breakpoints([0, 36, 84, 170])\n", "print(\"x_pts:\", x_pts1.values)\n", "print(\"y_pts:\", y_pts1.values)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:32.397039Z", - "start_time": "2026-04-01T07:27:32.353962Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.200170Z", "iopub.status.busy": "2026-03-06T11:51:29.200087Z", "iopub.status.idle": "2026-03-06T11:51:29.266847Z", "shell.execute_reply": "2026-03-06T11:51:29.266379Z", "shell.execute_reply.started": "2026-03-06T11:51:29.200161Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:12.547183Z", + "start_time": "2026-04-01T07:32:12.503997Z" } }, - "outputs": [], "source": [ "m1 = linopy.Model()\n", "\n", @@ -157,70 +155,72 @@ "demand1 = xr.DataArray([50, 80, 30], coords=[time])\n", "m1.add_constraints(power >= demand1, name=\"demand\")\n", "m1.add_objective(fuel.sum())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:32.442855Z", - "start_time": "2026-04-01T07:27:32.401364Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.267522Z", "iopub.status.busy": "2026-03-06T11:51:29.267433Z", "iopub.status.idle": "2026-03-06T11:51:29.326758Z", "shell.execute_reply": "2026-03-06T11:51:29.326518Z", "shell.execute_reply.started": "2026-03-06T11:51:29.267514Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:12.592196Z", + "start_time": "2026-04-01T07:32:12.549730Z" } }, - "outputs": [], "source": [ "m1.solve(reformulate_sos=\"auto\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:32.466547Z", - "start_time": "2026-04-01T07:27:32.460144Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.327139Z", "iopub.status.busy": "2026-03-06T11:51:29.327044Z", "iopub.status.idle": "2026-03-06T11:51:29.339334Z", "shell.execute_reply": "2026-03-06T11:51:29.338974Z", "shell.execute_reply.started": "2026-03-06T11:51:29.327130Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:12.602130Z", + "start_time": "2026-04-01T07:32:12.597104Z" } }, - "outputs": [], "source": [ "m1.solution[[\"power\", \"fuel\"]].to_pandas()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:32.579749Z", - "start_time": "2026-04-01T07:27:32.472505Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.339689Z", "iopub.status.busy": "2026-03-06T11:51:29.339608Z", "iopub.status.idle": "2026-03-06T11:51:29.489677Z", "shell.execute_reply": "2026-03-06T11:51:29.489280Z", "shell.execute_reply.started": "2026-03-06T11:51:29.339680Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:12.819361Z", + "start_time": "2026-04-01T07:32:12.610173Z" } }, - "outputs": [], "source": [ "plot_pwl_results(m1, x_pts1, y_pts1, demand1, color=\"C0\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -235,45 +235,43 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:32.589529Z", - "start_time": "2026-04-01T07:27:32.586129Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.490092Z", "iopub.status.busy": "2026-03-06T11:51:29.490011Z", "iopub.status.idle": "2026-03-06T11:51:29.500894Z", "shell.execute_reply": "2026-03-06T11:51:29.500558Z", "shell.execute_reply.started": "2026-03-06T11:51:29.490084Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:12.846270Z", + "start_time": "2026-04-01T07:32:12.827387Z" } }, - "outputs": [], "source": [ "x_pts2 = linopy.breakpoints([0, 50, 100, 150])\n", "y_pts2 = linopy.breakpoints([0, 55, 130, 225])\n", "print(\"x_pts:\", x_pts2.values)\n", "print(\"y_pts:\", y_pts2.values)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:32.664822Z", - "start_time": "2026-04-01T07:27:32.597724Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.501317Z", "iopub.status.busy": "2026-03-06T11:51:29.501216Z", "iopub.status.idle": "2026-03-06T11:51:29.604024Z", "shell.execute_reply": "2026-03-06T11:51:29.603543Z", "shell.execute_reply.started": "2026-03-06T11:51:29.501307Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:12.971238Z", + "start_time": "2026-04-01T07:32:12.863787Z" } }, - "outputs": [], "source": [ "m2 = linopy.Model()\n", "\n", @@ -292,70 +290,72 @@ "demand2 = xr.DataArray([80, 120, 50], coords=[time])\n", "m2.add_constraints(power >= demand2, name=\"demand\")\n", "m2.add_objective(fuel.sum())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:32.721419Z", - "start_time": "2026-04-01T07:27:32.668595Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.604434Z", "iopub.status.busy": "2026-03-06T11:51:29.604359Z", "iopub.status.idle": "2026-03-06T11:51:29.680947Z", "shell.execute_reply": "2026-03-06T11:51:29.680667Z", "shell.execute_reply.started": "2026-03-06T11:51:29.604427Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:13.015503Z", + "start_time": "2026-04-01T07:32:12.973599Z" } }, - "outputs": [], "source": [ "m2.solve(reformulate_sos=\"auto\");" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:32.733739Z", - "start_time": "2026-04-01T07:27:32.727737Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.681833Z", "iopub.status.busy": "2026-03-06T11:51:29.681725Z", "iopub.status.idle": "2026-03-06T11:51:29.698558Z", "shell.execute_reply": "2026-03-06T11:51:29.698011Z", "shell.execute_reply.started": "2026-03-06T11:51:29.681822Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:13.024448Z", + "start_time": "2026-04-01T07:32:13.020260Z" } }, - "outputs": [], "source": [ "m2.solution[[\"power\", \"fuel\"]].to_pandas()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:32.830743Z", - "start_time": "2026-04-01T07:27:32.743076Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.699350Z", "iopub.status.busy": "2026-03-06T11:51:29.699116Z", "iopub.status.idle": "2026-03-06T11:51:29.852000Z", "shell.execute_reply": "2026-03-06T11:51:29.851741Z", "shell.execute_reply.started": "2026-03-06T11:51:29.699334Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:13.117100Z", + "start_time": "2026-04-01T07:32:13.033726Z" } }, - "outputs": [], "source": [ "plot_pwl_results(m2, x_pts2, y_pts2, demand2, color=\"C1\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -375,21 +375,19 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:32.839177Z", - "start_time": "2026-04-01T07:27:32.835378Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.852397Z", "iopub.status.busy": "2026-03-06T11:51:29.852305Z", "iopub.status.idle": "2026-03-06T11:51:29.866500Z", "shell.execute_reply": "2026-03-06T11:51:29.866141Z", "shell.execute_reply.started": "2026-03-06T11:51:29.852387Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:13.123748Z", + "start_time": "2026-04-01T07:32:13.119898Z" } }, - "outputs": [], "source": [ "# x-breakpoints define where each segment lives on the power axis\n", "# y-breakpoints define the corresponding cost values\n", @@ -397,25 +395,25 @@ "y_seg = linopy.segments([(0, 0), (125, 200)])\n", "print(\"x segments:\\n\", x_seg.to_pandas())\n", "print(\"y segments:\\n\", y_seg.to_pandas())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:32.907702Z", - "start_time": "2026-04-01T07:27:32.845651Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.866940Z", "iopub.status.busy": "2026-03-06T11:51:29.866839Z", "iopub.status.idle": "2026-03-06T11:51:29.955272Z", "shell.execute_reply": "2026-03-06T11:51:29.954810Z", "shell.execute_reply.started": "2026-03-06T11:51:29.866931Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:13.188628Z", + "start_time": "2026-04-01T07:32:13.127495Z" } }, - "outputs": [], "source": [ "m3 = linopy.Model()\n", "\n", @@ -434,49 +432,51 @@ "demand3 = xr.DataArray([10, 70, 90], coords=[time])\n", "m3.add_constraints(power + backup >= demand3, name=\"demand\")\n", "m3.add_objective((cost + 10 * backup).sum())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:32.982947Z", - "start_time": "2026-04-01T07:27:32.916103Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.955750Z", "iopub.status.busy": "2026-03-06T11:51:29.955667Z", "iopub.status.idle": "2026-03-06T11:51:30.027311Z", "shell.execute_reply": "2026-03-06T11:51:30.026945Z", "shell.execute_reply.started": "2026-03-06T11:51:29.955741Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:13.239657Z", + "start_time": "2026-04-01T07:32:13.190945Z" } }, - "outputs": [], "source": [ "m3.solve(reformulate_sos=\"auto\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.000867Z", - "start_time": "2026-04-01T07:27:32.993009Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.028114Z", "iopub.status.busy": "2026-03-06T11:51:30.027864Z", "iopub.status.idle": "2026-03-06T11:51:30.043138Z", "shell.execute_reply": "2026-03-06T11:51:30.042813Z", "shell.execute_reply.started": "2026-03-06T11:51:30.028095Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:13.249810Z", + "start_time": "2026-04-01T07:32:13.244350Z" } }, - "outputs": [], "source": [ "m3.solution[[\"power\", \"cost\", \"backup\"]].to_pandas()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -497,21 +497,19 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.066507Z", - "start_time": "2026-04-01T07:27:33.015928Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.043492Z", "iopub.status.busy": "2026-03-06T11:51:30.043410Z", "iopub.status.idle": "2026-03-06T11:51:30.113382Z", "shell.execute_reply": "2026-03-06T11:51:30.112320Z", "shell.execute_reply.started": "2026-03-06T11:51:30.043484Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:13.297746Z", + "start_time": "2026-04-01T07:32:13.257081Z" } }, - "outputs": [], "source": [ "x_pts4 = linopy.breakpoints([0, 40, 80, 120])\n", "# Concave curve: decreasing marginal fuel per MW\n", @@ -536,70 +534,72 @@ "m4.add_constraints(power == demand4, name=\"demand\")\n", "# Maximize fuel (to push against the upper bound)\n", "m4.add_objective(-fuel.sum())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.114652Z", - "start_time": "2026-04-01T07:27:33.070973Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.113818Z", "iopub.status.busy": "2026-03-06T11:51:30.113727Z", "iopub.status.idle": "2026-03-06T11:51:30.171329Z", "shell.execute_reply": "2026-03-06T11:51:30.170942Z", "shell.execute_reply.started": "2026-03-06T11:51:30.113810Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:13.332853Z", + "start_time": "2026-04-01T07:32:13.300049Z" } }, - "outputs": [], "source": [ "m4.solve(reformulate_sos=\"auto\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.125893Z", - "start_time": "2026-04-01T07:27:33.121227Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.172009Z", "iopub.status.busy": "2026-03-06T11:51:30.171791Z", "iopub.status.idle": "2026-03-06T11:51:30.191956Z", "shell.execute_reply": "2026-03-06T11:51:30.191556Z", "shell.execute_reply.started": "2026-03-06T11:51:30.171993Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:13.338441Z", + "start_time": "2026-04-01T07:32:13.334952Z" } }, - "outputs": [], "source": [ "m4.solution[[\"power\", \"fuel\"]].to_pandas()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.249644Z", - "start_time": "2026-04-01T07:27:33.133166Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.192604Z", "iopub.status.busy": "2026-03-06T11:51:30.192376Z", "iopub.status.idle": "2026-03-06T11:51:30.345074Z", "shell.execute_reply": "2026-03-06T11:51:30.344642Z", "shell.execute_reply.started": "2026-03-06T11:51:30.192590Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:13.437164Z", + "start_time": "2026-04-01T07:32:13.350877Z" } }, - "outputs": [], "source": [ "plot_pwl_results(m4, x_pts4, y_pts4, demand4, color=\"C4\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -614,27 +614,27 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.258656Z", - "start_time": "2026-04-01T07:27:33.254569Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.345523Z", "iopub.status.busy": "2026-03-06T11:51:30.345404Z", "iopub.status.idle": "2026-03-06T11:51:30.357312Z", "shell.execute_reply": "2026-03-06T11:51:30.356954Z", "shell.execute_reply.started": "2026-03-06T11:51:30.345513Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T07:32:13.449751Z", + "start_time": "2026-04-01T07:32:13.445084Z" } }, - "outputs": [], "source": [ "# Marginal costs: $1.1/MW for 0-50, $1.5/MW for 50-100, $1.9/MW for 100-150\n", "x_pts5 = linopy.breakpoints([0, 50, 100, 150])\n", "y_pts5 = linopy.breakpoints(slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0)\n", "print(\"y breakpoints from slopes:\", y_pts5.values)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -643,14 +643,12 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.269756Z", - "start_time": "2026-04-01T07:27:33.266342Z" + "end_time": "2026-04-01T07:32:13.463949Z", + "start_time": "2026-04-01T07:32:13.461020Z" } }, - "outputs": [], "source": [ "# Unit parameters: operates between 30-100 MW when on\n", "p_min, p_max = 30, 100\n", @@ -661,18 +659,18 @@ "y_pts6 = linopy.breakpoints([fuel_min, 90, fuel_max])\n", "print(\"Power breakpoints:\", x_pts6.values)\n", "print(\"Fuel breakpoints: \", y_pts6.values)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.386556Z", - "start_time": "2026-04-01T07:27:33.277128Z" + "end_time": "2026-04-01T07:32:13.555098Z", + "start_time": "2026-04-01T07:32:13.468165Z" } }, - "outputs": [], "source": [ "m6 = linopy.Model()\n", "\n", @@ -700,49 +698,51 @@ "\n", "# Objective: fuel + startup cost + backup at $5/MW\n", "m6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.460332Z", - "start_time": "2026-04-01T07:27:33.391322Z" + "end_time": "2026-04-01T07:32:13.618069Z", + "start_time": "2026-04-01T07:32:13.557318Z" } }, - "outputs": [], "source": [ "m6.solve(reformulate_sos=\"auto\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.476944Z", - "start_time": "2026-04-01T07:27:33.469186Z" + "end_time": "2026-04-01T07:32:13.625349Z", + "start_time": "2026-04-01T07:32:13.620557Z" } }, - "outputs": [], "source": [ "m6.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.596866Z", - "start_time": "2026-04-01T07:27:33.483794Z" + "end_time": "2026-04-01T07:32:13.723640Z", + "start_time": "2026-04-01T07:32:13.634403Z" } }, - "outputs": [], "source": [ "plot_pwl_results(m6, x_pts6, y_pts6, demand6, color=\"C2\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -756,14 +756,12 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.605404Z", - "start_time": "2026-04-01T07:27:33.601106Z" + "end_time": "2026-04-01T07:32:13.730202Z", + "start_time": "2026-04-01T07:32:13.726545Z" } }, - "outputs": [], "source": [ "# CHP operating points: as load increases, power, fuel, and heat all change\n", "bp_chp = linopy.breakpoints(\n", @@ -776,76 +774,124 @@ ")\n", "print(\"CHP breakpoints:\")\n", "print(bp_chp.to_pandas())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.702569Z", - "start_time": "2026-04-01T07:27:33.615111Z" + "end_time": "2026-04-01T07:32:13.772266Z", + "start_time": "2026-04-01T07:32:13.733024Z" } }, + "source": "m7 = linopy.Model()\n\npower = m7.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\nfuel = m7.add_variables(name=\"fuel\", lower=0, coords=[time])\nheat = m7.add_variables(name=\"heat\", lower=0, coords=[time])\n\n# N-variable API: link power, fuel, and heat through shared breakpoints\nm7.add_piecewise_constraints(\n exprs={\"power\": power, \"fuel\": fuel, \"heat\": heat},\n breakpoints=bp_chp,\n name=\"chp\",\n method=\"sos2\",\n)\n\n# Fixed power dispatch determines the operating point — fuel and heat follow\npower_dispatch = xr.DataArray([20, 60, 90], coords=[time])\nm7.add_constraints(power == power_dispatch, name=\"power_dispatch\")\n\nm7.add_objective(fuel.sum())", "outputs": [], - "source": [ - "m7 = linopy.Model()\n", - "\n", - "power = m7.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", - "fuel = m7.add_variables(name=\"fuel\", lower=0, coords=[time])\n", - "heat = m7.add_variables(name=\"heat\", lower=0, coords=[time])\n", - "\n", - "# N-variable API: link power, fuel, and heat through shared breakpoints\n", - "m7.add_piecewise_constraints(\n", - " exprs={\"power\": power, \"fuel\": fuel, \"heat\": heat},\n", - " breakpoints=bp_chp,\n", - " name=\"chp\",\n", - " method=\"sos2\",\n", - ")\n", - "\n", - "demand7 = xr.DataArray([50, 80, 30], coords=[time])\n", - "m7.add_constraints(power >= demand7, name=\"elec_demand\")\n", - "m7.add_objective(fuel.sum())" - ] + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.751671Z", - "start_time": "2026-04-01T07:27:33.706974Z" + "end_time": "2026-04-01T07:32:13.813229Z", + "start_time": "2026-04-01T07:32:13.774618Z" } }, - "outputs": [], "source": [ "m7.solve(reformulate_sos=\"auto\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.772537Z", - "start_time": "2026-04-01T07:27:33.765173Z" + "end_time": "2026-04-01T07:32:31.498938Z", + "start_time": "2026-04-01T07:32:31.490658Z" } }, - "outputs": [], - "source": [ - "m7.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas()" - ] + "source": "m7.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas().round(2)", + "outputs": [ + { + "data": { + "text/plain": [ + " power fuel heat\n", + "time \n", + "1 20.0 26.67 16.67\n", + "2 60.0 85.00 55.00\n", + "3 90.0 141.25 85.00" + ], + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
powerfuelheat
time
120.026.6716.67
260.085.0055.00
390.0141.2585.00
\n", + "
" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:27:33.883112Z", - "start_time": "2026-04-01T07:27:33.777156Z" + "end_time": "2026-04-01T07:32:13.927411Z", + "start_time": "2026-04-01T07:32:13.831574Z" } }, - "outputs": [], "source": [ "sol = m7.solution\n", "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3.5))\n", @@ -885,7 +931,9 @@ ")\n", "ax2.legend()\n", "plt.tight_layout()" - ] + ], + "outputs": [], + "execution_count": null } ], "metadata": { diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 2103393a..4a9fbd9e 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -1248,6 +1248,11 @@ def _add_piecewise_nvar( computed_mask = computed_mask.broadcast_like(breakpoints_da) lambda_mask = computed_mask.any(dim=link_dim) + # Broadcast breakpoints to cover expression dimensions (e.g. time) + breakpoints_da = _broadcast_points( + breakpoints_da, *exprs.values(), disjunctive=False + ) + target_expr = _build_stacked_expr(model, exprs, breakpoints_da, link_dim) extra = _extra_coords(breakpoints_da, dim, link_dim) lambda_coords = extra + [pd.Index(breakpoints_da.coords[dim].values, name=dim)] From 457d39211f52d6c6ca5384cee1d9fdd29c29ca6c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:36:33 +0200 Subject: [PATCH 05/30] docs: generalize plot_pwl_results for N-variable case The plotting helper now accepts a single breakpoints DataArray with a "var" dimension, supporting both 2-variable and N-variable examples. Replaces the inline CHP plot with a single function call. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/piecewise-linear-constraints.ipynb | 574 +++++++++----------- 1 file changed, 258 insertions(+), 316 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 9db3bf96..bfc87c20 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -7,19 +7,21 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.292583Z", + "start_time": "2026-04-01T07:35:36.286274Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.167007Z", "iopub.status.busy": "2026-03-06T11:51:29.166576Z", "iopub.status.idle": "2026-03-06T11:51:29.185103Z", "shell.execute_reply": "2026-03-06T11:51:29.184712Z", "shell.execute_reply.started": "2026-03-06T11:51:29.166974Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:12.466618Z", - "start_time": "2026-04-01T07:32:11.729309Z" } }, + "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "import pandas as pd\n", @@ -30,26 +32,47 @@ "time = pd.Index([1, 2, 3], name=\"time\")\n", "\n", "\n", - "def plot_pwl_results(\n", - " model, x_pts, y_pts, demand, x_name=\"power\", y_name=\"fuel\", color=\"C0\"\n", - "):\n", - " \"\"\"Plot PWL curve with operating points and dispatch vs demand.\"\"\"\n", + "def plot_pwl_results(model, breakpoints, demand, *, x_name=\"power\", color=\"C0\"):\n", + " \"\"\"\n", + " Plot PWL curves with operating points and dispatch vs demand.\n", + "\n", + " Parameters\n", + " ----------\n", + " model : linopy.Model\n", + " Solved model.\n", + " breakpoints : DataArray\n", + " Breakpoints array. For 2-variable cases pass a DataArray with a\n", + " \"var\" dimension containing two coordinates (x and y variable names).\n", + " Alternatively pass two separate arrays and they will be stacked.\n", + " demand : DataArray\n", + " Demand time series (plotted as step line).\n", + " x_name : str\n", + " Name of the x-axis variable (used for the curve plot).\n", + " color : str\n", + " Base color for the plot.\n", + " \"\"\"\n", " sol = model.solution\n", + " var_names = list(breakpoints.coords[\"var\"].values)\n", + " bp_x = breakpoints.sel(var=x_name).values\n", + "\n", " fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3.5))\n", "\n", - " # Left: PWL curve with operating points\n", - " ax1.plot(\n", - " x_pts.values.flat, y_pts.values.flat, \"o-\", color=color, label=\"Breakpoints\"\n", - " )\n", - " for t in time:\n", - " ax1.plot(\n", - " sol[x_name].sel(time=t),\n", - " sol[y_name].sel(time=t),\n", - " \"s\",\n", - " ms=10,\n", - " label=f\"t={t}\",\n", - " )\n", - " ax1.set(xlabel=x_name.title(), ylabel=y_name.title(), title=\"Heat rate curve\")\n", + " # Left: breakpoint curves with operating points\n", + " colors = [f\"C{i}\" for i in range(len(var_names))]\n", + " for var, c in zip(var_names, colors):\n", + " if var == x_name:\n", + " continue\n", + " bp_y = breakpoints.sel(var=var).values\n", + " ax1.plot(bp_x, bp_y, \"o-\", color=c, label=f\"{var} (breakpoints)\")\n", + " for t in time:\n", + " ax1.plot(\n", + " float(sol[x_name].sel(time=t)),\n", + " float(sol[var].sel(time=t)),\n", + " \"D\",\n", + " color=c,\n", + " ms=10,\n", + " )\n", + " ax1.set(xlabel=x_name.title(), title=\"PWL curve\")\n", " ax1.legend()\n", "\n", " # Right: dispatch vs demand\n", @@ -82,9 +105,7 @@ " )\n", " ax2.legend()\n", " plt.tight_layout()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -99,43 +120,45 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.312257Z", + "start_time": "2026-04-01T07:35:36.308964Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.185693Z", "iopub.status.busy": "2026-03-06T11:51:29.185601Z", "iopub.status.idle": "2026-03-06T11:51:29.199760Z", "shell.execute_reply": "2026-03-06T11:51:29.199416Z", "shell.execute_reply.started": "2026-03-06T11:51:29.185683Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:12.501563Z", - "start_time": "2026-04-01T07:32:12.469248Z" } }, + "outputs": [], "source": [ "x_pts1 = linopy.breakpoints([0, 30, 60, 100])\n", "y_pts1 = linopy.breakpoints([0, 36, 84, 170])\n", "print(\"x_pts:\", x_pts1.values)\n", "print(\"y_pts:\", y_pts1.values)" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.365214Z", + "start_time": "2026-04-01T07:35:36.322511Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.200170Z", "iopub.status.busy": "2026-03-06T11:51:29.200087Z", "iopub.status.idle": "2026-03-06T11:51:29.266847Z", "shell.execute_reply": "2026-03-06T11:51:29.266379Z", "shell.execute_reply.started": "2026-03-06T11:51:29.200161Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:12.547183Z", - "start_time": "2026-04-01T07:32:12.503997Z" } }, + "outputs": [], "source": [ "m1 = linopy.Model()\n", "\n", @@ -155,72 +178,71 @@ "demand1 = xr.DataArray([50, 80, 30], coords=[time])\n", "m1.add_constraints(power >= demand1, name=\"demand\")\n", "m1.add_objective(fuel.sum())" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.410875Z", + "start_time": "2026-04-01T07:35:36.367557Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.267522Z", "iopub.status.busy": "2026-03-06T11:51:29.267433Z", "iopub.status.idle": "2026-03-06T11:51:29.326758Z", "shell.execute_reply": "2026-03-06T11:51:29.326518Z", "shell.execute_reply.started": "2026-03-06T11:51:29.267514Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:12.592196Z", - "start_time": "2026-04-01T07:32:12.549730Z" } }, + "outputs": [], "source": [ "m1.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.424283Z", + "start_time": "2026-04-01T07:35:36.419372Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.327139Z", "iopub.status.busy": "2026-03-06T11:51:29.327044Z", "iopub.status.idle": "2026-03-06T11:51:29.339334Z", "shell.execute_reply": "2026-03-06T11:51:29.338974Z", "shell.execute_reply.started": "2026-03-06T11:51:29.327130Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:12.602130Z", - "start_time": "2026-04-01T07:32:12.597104Z" } }, + "outputs": [], "source": [ "m1.solution[[\"power\", \"fuel\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.525484Z", + "start_time": "2026-04-01T07:35:36.436334Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.339689Z", "iopub.status.busy": "2026-03-06T11:51:29.339608Z", "iopub.status.idle": "2026-03-06T11:51:29.489677Z", "shell.execute_reply": "2026-03-06T11:51:29.489280Z", "shell.execute_reply.started": "2026-03-06T11:51:29.339680Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:12.819361Z", - "start_time": "2026-04-01T07:32:12.610173Z" } }, - "source": [ - "plot_pwl_results(m1, x_pts1, y_pts1, demand1, color=\"C0\")" - ], "outputs": [], - "execution_count": null + "source": [ + "bp1 = xr.concat([x_pts1, y_pts1], dim=pd.Index([\"power\", \"fuel\"], name=\"var\"))\n", + "plot_pwl_results(m1, bp1, demand1, color=\"C0\")" + ] }, { "cell_type": "markdown", @@ -235,43 +257,45 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.531430Z", + "start_time": "2026-04-01T07:35:36.528406Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.490092Z", "iopub.status.busy": "2026-03-06T11:51:29.490011Z", "iopub.status.idle": "2026-03-06T11:51:29.500894Z", "shell.execute_reply": "2026-03-06T11:51:29.500558Z", "shell.execute_reply.started": "2026-03-06T11:51:29.490084Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:12.846270Z", - "start_time": "2026-04-01T07:32:12.827387Z" } }, + "outputs": [], "source": [ "x_pts2 = linopy.breakpoints([0, 50, 100, 150])\n", "y_pts2 = linopy.breakpoints([0, 55, 130, 225])\n", "print(\"x_pts:\", x_pts2.values)\n", "print(\"y_pts:\", y_pts2.values)" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.605829Z", + "start_time": "2026-04-01T07:35:36.538213Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.501317Z", "iopub.status.busy": "2026-03-06T11:51:29.501216Z", "iopub.status.idle": "2026-03-06T11:51:29.604024Z", "shell.execute_reply": "2026-03-06T11:51:29.603543Z", "shell.execute_reply.started": "2026-03-06T11:51:29.501307Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:12.971238Z", - "start_time": "2026-04-01T07:32:12.863787Z" } }, + "outputs": [], "source": [ "m2 = linopy.Model()\n", "\n", @@ -290,72 +314,71 @@ "demand2 = xr.DataArray([80, 120, 50], coords=[time])\n", "m2.add_constraints(power >= demand2, name=\"demand\")\n", "m2.add_objective(fuel.sum())" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.661877Z", + "start_time": "2026-04-01T07:35:36.609352Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.604434Z", "iopub.status.busy": "2026-03-06T11:51:29.604359Z", "iopub.status.idle": "2026-03-06T11:51:29.680947Z", "shell.execute_reply": "2026-03-06T11:51:29.680667Z", "shell.execute_reply.started": "2026-03-06T11:51:29.604427Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.015503Z", - "start_time": "2026-04-01T07:32:12.973599Z" } }, + "outputs": [], "source": [ "m2.solve(reformulate_sos=\"auto\");" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.674590Z", + "start_time": "2026-04-01T07:35:36.669960Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.681833Z", "iopub.status.busy": "2026-03-06T11:51:29.681725Z", "iopub.status.idle": "2026-03-06T11:51:29.698558Z", "shell.execute_reply": "2026-03-06T11:51:29.698011Z", "shell.execute_reply.started": "2026-03-06T11:51:29.681822Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.024448Z", - "start_time": "2026-04-01T07:32:13.020260Z" } }, + "outputs": [], "source": [ "m2.solution[[\"power\", \"fuel\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.766218Z", + "start_time": "2026-04-01T07:35:36.687140Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.699350Z", "iopub.status.busy": "2026-03-06T11:51:29.699116Z", "iopub.status.idle": "2026-03-06T11:51:29.852000Z", "shell.execute_reply": "2026-03-06T11:51:29.851741Z", "shell.execute_reply.started": "2026-03-06T11:51:29.699334Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.117100Z", - "start_time": "2026-04-01T07:32:13.033726Z" } }, - "source": [ - "plot_pwl_results(m2, x_pts2, y_pts2, demand2, color=\"C1\")" - ], "outputs": [], - "execution_count": null + "source": [ + "bp2 = xr.concat([x_pts2, y_pts2], dim=pd.Index([\"power\", \"fuel\"], name=\"var\"))\n", + "plot_pwl_results(m2, bp2, demand2, color=\"C1\")" + ] }, { "cell_type": "markdown", @@ -375,19 +398,21 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.773687Z", + "start_time": "2026-04-01T07:35:36.769193Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.852397Z", "iopub.status.busy": "2026-03-06T11:51:29.852305Z", "iopub.status.idle": "2026-03-06T11:51:29.866500Z", "shell.execute_reply": "2026-03-06T11:51:29.866141Z", "shell.execute_reply.started": "2026-03-06T11:51:29.852387Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.123748Z", - "start_time": "2026-04-01T07:32:13.119898Z" } }, + "outputs": [], "source": [ "# x-breakpoints define where each segment lives on the power axis\n", "# y-breakpoints define the corresponding cost values\n", @@ -395,25 +420,25 @@ "y_seg = linopy.segments([(0, 0), (125, 200)])\n", "print(\"x segments:\\n\", x_seg.to_pandas())\n", "print(\"y segments:\\n\", y_seg.to_pandas())" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.862477Z", + "start_time": "2026-04-01T07:35:36.784561Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.866940Z", "iopub.status.busy": "2026-03-06T11:51:29.866839Z", "iopub.status.idle": "2026-03-06T11:51:29.955272Z", "shell.execute_reply": "2026-03-06T11:51:29.954810Z", "shell.execute_reply.started": "2026-03-06T11:51:29.866931Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.188628Z", - "start_time": "2026-04-01T07:32:13.127495Z" } }, + "outputs": [], "source": [ "m3 = linopy.Model()\n", "\n", @@ -432,51 +457,49 @@ "demand3 = xr.DataArray([10, 70, 90], coords=[time])\n", "m3.add_constraints(power + backup >= demand3, name=\"demand\")\n", "m3.add_objective((cost + 10 * backup).sum())" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.925139Z", + "start_time": "2026-04-01T07:35:36.865201Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.955750Z", "iopub.status.busy": "2026-03-06T11:51:29.955667Z", "iopub.status.idle": "2026-03-06T11:51:30.027311Z", "shell.execute_reply": "2026-03-06T11:51:30.026945Z", "shell.execute_reply.started": "2026-03-06T11:51:29.955741Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.239657Z", - "start_time": "2026-04-01T07:32:13.190945Z" } }, + "outputs": [], "source": [ "m3.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.935504Z", + "start_time": "2026-04-01T07:35:36.928757Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.028114Z", "iopub.status.busy": "2026-03-06T11:51:30.027864Z", "iopub.status.idle": "2026-03-06T11:51:30.043138Z", "shell.execute_reply": "2026-03-06T11:51:30.042813Z", "shell.execute_reply.started": "2026-03-06T11:51:30.028095Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.249810Z", - "start_time": "2026-04-01T07:32:13.244350Z" } }, + "outputs": [], "source": [ "m3.solution[[\"power\", \"cost\", \"backup\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -497,19 +520,21 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.990196Z", + "start_time": "2026-04-01T07:35:36.947234Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.043492Z", "iopub.status.busy": "2026-03-06T11:51:30.043410Z", "iopub.status.idle": "2026-03-06T11:51:30.113382Z", "shell.execute_reply": "2026-03-06T11:51:30.112320Z", "shell.execute_reply.started": "2026-03-06T11:51:30.043484Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.297746Z", - "start_time": "2026-04-01T07:32:13.257081Z" } }, + "outputs": [], "source": [ "x_pts4 = linopy.breakpoints([0, 40, 80, 120])\n", "# Concave curve: decreasing marginal fuel per MW\n", @@ -534,72 +559,71 @@ "m4.add_constraints(power == demand4, name=\"demand\")\n", "# Maximize fuel (to push against the upper bound)\n", "m4.add_objective(-fuel.sum())" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:37.024642Z", + "start_time": "2026-04-01T07:35:36.992590Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.113818Z", "iopub.status.busy": "2026-03-06T11:51:30.113727Z", "iopub.status.idle": "2026-03-06T11:51:30.171329Z", "shell.execute_reply": "2026-03-06T11:51:30.170942Z", "shell.execute_reply.started": "2026-03-06T11:51:30.113810Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.332853Z", - "start_time": "2026-04-01T07:32:13.300049Z" } }, + "outputs": [], "source": [ "m4.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:37.032695Z", + "start_time": "2026-04-01T07:35:37.028371Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.172009Z", "iopub.status.busy": "2026-03-06T11:51:30.171791Z", "iopub.status.idle": "2026-03-06T11:51:30.191956Z", "shell.execute_reply": "2026-03-06T11:51:30.191556Z", "shell.execute_reply.started": "2026-03-06T11:51:30.171993Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.338441Z", - "start_time": "2026-04-01T07:32:13.334952Z" } }, + "outputs": [], "source": [ "m4.solution[[\"power\", \"fuel\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:37.125808Z", + "start_time": "2026-04-01T07:35:37.037137Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.192604Z", "iopub.status.busy": "2026-03-06T11:51:30.192376Z", "iopub.status.idle": "2026-03-06T11:51:30.345074Z", "shell.execute_reply": "2026-03-06T11:51:30.344642Z", "shell.execute_reply.started": "2026-03-06T11:51:30.192590Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.437164Z", - "start_time": "2026-04-01T07:32:13.350877Z" } }, - "source": [ - "plot_pwl_results(m4, x_pts4, y_pts4, demand4, color=\"C4\")" - ], "outputs": [], - "execution_count": null + "source": [ + "bp4 = xr.concat([x_pts4, y_pts4], dim=pd.Index([\"power\", \"fuel\"], name=\"var\"))\n", + "plot_pwl_results(m4, bp4, demand4, color=\"C4\")" + ] }, { "cell_type": "markdown", @@ -614,27 +638,27 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:37.137074Z", + "start_time": "2026-04-01T07:35:37.133725Z" + }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.345523Z", "iopub.status.busy": "2026-03-06T11:51:30.345404Z", "iopub.status.idle": "2026-03-06T11:51:30.357312Z", "shell.execute_reply": "2026-03-06T11:51:30.356954Z", "shell.execute_reply.started": "2026-03-06T11:51:30.345513Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.449751Z", - "start_time": "2026-04-01T07:32:13.445084Z" } }, + "outputs": [], "source": [ "# Marginal costs: $1.1/MW for 0-50, $1.5/MW for 50-100, $1.9/MW for 100-150\n", "x_pts5 = linopy.breakpoints([0, 50, 100, 150])\n", "y_pts5 = linopy.breakpoints(slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0)\n", "print(\"y breakpoints from slopes:\", y_pts5.values)" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -643,12 +667,14 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.463949Z", - "start_time": "2026-04-01T07:32:13.461020Z" + "end_time": "2026-04-01T07:35:37.147393Z", + "start_time": "2026-04-01T07:35:37.143502Z" } }, + "outputs": [], "source": [ "# Unit parameters: operates between 30-100 MW when on\n", "p_min, p_max = 30, 100\n", @@ -659,18 +685,18 @@ "y_pts6 = linopy.breakpoints([fuel_min, 90, fuel_max])\n", "print(\"Power breakpoints:\", x_pts6.values)\n", "print(\"Fuel breakpoints: \", y_pts6.values)" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.555098Z", - "start_time": "2026-04-01T07:32:13.468165Z" + "end_time": "2026-04-01T07:35:37.274340Z", + "start_time": "2026-04-01T07:35:37.160988Z" } }, + "outputs": [], "source": [ "m6 = linopy.Model()\n", "\n", @@ -698,51 +724,50 @@ "\n", "# Objective: fuel + startup cost + backup at $5/MW\n", "m6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.618069Z", - "start_time": "2026-04-01T07:32:13.557318Z" + "end_time": "2026-04-01T07:35:37.421418Z", + "start_time": "2026-04-01T07:35:37.284234Z" } }, + "outputs": [], "source": [ "m6.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.625349Z", - "start_time": "2026-04-01T07:32:13.620557Z" + "end_time": "2026-04-01T07:35:37.434721Z", + "start_time": "2026-04-01T07:35:37.429918Z" } }, + "outputs": [], "source": [ "m6.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.723640Z", - "start_time": "2026-04-01T07:32:13.634403Z" + "end_time": "2026-04-01T07:35:37.532796Z", + "start_time": "2026-04-01T07:35:37.442775Z" } }, - "source": [ - "plot_pwl_results(m6, x_pts6, y_pts6, demand6, color=\"C2\")" - ], "outputs": [], - "execution_count": null + "source": [ + "bp6 = xr.concat([x_pts6, y_pts6], dim=pd.Index([\"power\", \"fuel\"], name=\"var\"))\n", + "plot_pwl_results(m6, bp6, demand6, color=\"C2\")" + ] }, { "cell_type": "markdown", @@ -756,12 +781,14 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.730202Z", - "start_time": "2026-04-01T07:32:13.726545Z" + "end_time": "2026-04-01T07:35:37.540101Z", + "start_time": "2026-04-01T07:35:37.535579Z" } }, + "outputs": [], "source": [ "# CHP operating points: as load increases, power, fuel, and heat all change\n", "bp_chp = linopy.breakpoints(\n", @@ -774,166 +801,81 @@ ")\n", "print(\"CHP breakpoints:\")\n", "print(bp_chp.to_pandas())" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.772266Z", - "start_time": "2026-04-01T07:32:13.733024Z" + "end_time": "2026-04-01T07:35:37.590068Z", + "start_time": "2026-04-01T07:35:37.546834Z" } }, - "source": "m7 = linopy.Model()\n\npower = m7.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\nfuel = m7.add_variables(name=\"fuel\", lower=0, coords=[time])\nheat = m7.add_variables(name=\"heat\", lower=0, coords=[time])\n\n# N-variable API: link power, fuel, and heat through shared breakpoints\nm7.add_piecewise_constraints(\n exprs={\"power\": power, \"fuel\": fuel, \"heat\": heat},\n breakpoints=bp_chp,\n name=\"chp\",\n method=\"sos2\",\n)\n\n# Fixed power dispatch determines the operating point — fuel and heat follow\npower_dispatch = xr.DataArray([20, 60, 90], coords=[time])\nm7.add_constraints(power == power_dispatch, name=\"power_dispatch\")\n\nm7.add_objective(fuel.sum())", "outputs": [], - "execution_count": null + "source": [ + "m7 = linopy.Model()\n", + "\n", + "power = m7.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", + "fuel = m7.add_variables(name=\"fuel\", lower=0, coords=[time])\n", + "heat = m7.add_variables(name=\"heat\", lower=0, coords=[time])\n", + "\n", + "# N-variable API: link power, fuel, and heat through shared breakpoints\n", + "m7.add_piecewise_constraints(\n", + " exprs={\"power\": power, \"fuel\": fuel, \"heat\": heat},\n", + " breakpoints=bp_chp,\n", + " name=\"chp\",\n", + " method=\"sos2\",\n", + ")\n", + "\n", + "# Fixed power dispatch determines the operating point — fuel and heat follow\n", + "power_dispatch = xr.DataArray([20, 60, 90], coords=[time])\n", + "m7.add_constraints(power == power_dispatch, name=\"power_dispatch\")\n", + "\n", + "m7.add_objective(fuel.sum())" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.813229Z", - "start_time": "2026-04-01T07:32:13.774618Z" + "end_time": "2026-04-01T07:35:37.635983Z", + "start_time": "2026-04-01T07:35:37.596785Z" } }, + "outputs": [], "source": [ "m7.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:32:31.498938Z", - "start_time": "2026-04-01T07:32:31.490658Z" + "end_time": "2026-04-01T07:35:37.662901Z", + "start_time": "2026-04-01T07:35:37.657464Z" } }, - "source": "m7.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas().round(2)", - "outputs": [ - { - "data": { - "text/plain": [ - " power fuel heat\n", - "time \n", - "1 20.0 26.67 16.67\n", - "2 60.0 85.00 55.00\n", - "3 90.0 141.25 85.00" - ], - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
powerfuelheat
time
120.026.6716.67
260.085.0055.00
390.0141.2585.00
\n", - "
" - ] - }, - "execution_count": 31, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": null + "outputs": [], + "source": [ + "m7.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas().round(2)" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:32:13.927411Z", - "start_time": "2026-04-01T07:32:13.831574Z" + "end_time": "2026-04-01T07:35:37.776394Z", + "start_time": "2026-04-01T07:35:37.679698Z" } }, - "source": [ - "sol = m7.solution\n", - "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3.5))\n", - "\n", - "# Left: breakpoint curves with operating points\n", - "bp_power = bp_chp.sel(var=\"power\").values\n", - "bp_fuel = bp_chp.sel(var=\"fuel\").values\n", - "bp_heat = bp_chp.sel(var=\"heat\").values\n", - "\n", - "ax1.plot(bp_power, bp_fuel, \"o-\", color=\"C0\", label=\"Fuel (breakpoints)\")\n", - "ax1.plot(bp_power, bp_heat, \"s--\", color=\"C1\", label=\"Heat (breakpoints)\")\n", - "for t in time:\n", - " p = float(sol[\"power\"].sel(time=t))\n", - " ax1.plot(p, float(sol[\"fuel\"].sel(time=t)), \"D\", color=\"C0\", ms=10)\n", - " ax1.plot(p, float(sol[\"heat\"].sel(time=t)), \"D\", color=\"C1\", ms=10)\n", - "ax1.set(xlabel=\"Power [MW]\", ylabel=\"Fuel / Heat\", title=\"CHP operating curve\")\n", - "ax1.legend()\n", - "\n", - "# Right: stacked dispatch\n", - "x = list(range(len(time)))\n", - "ax2.bar(x, sol[\"power\"].values, color=\"C0\", label=\"Power\")\n", - "ax2.bar(x, sol[\"heat\"].values, bottom=sol[\"power\"].values, color=\"C1\", label=\"Heat\")\n", - "ax2.bar(\n", - " x,\n", - " sol[\"fuel\"].values,\n", - " bottom=sol[\"power\"].values + sol[\"heat\"].values,\n", - " color=\"C2\",\n", - " alpha=0.5,\n", - " label=\"Fuel\",\n", - ")\n", - "ax2.set(\n", - " xlabel=\"Time\",\n", - " ylabel=\"Value\",\n", - " title=\"CHP dispatch\",\n", - " xticks=x,\n", - " xticklabels=time.values,\n", - ")\n", - "ax2.legend()\n", - "plt.tight_layout()" - ], "outputs": [], - "execution_count": null + "source": [ + "plot_pwl_results(m7, bp_chp, power_dispatch)" + ] } ], "metadata": { From ddc5c534b302915d99d33bf340a42fc99bdc13bd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:39:10 +0200 Subject: [PATCH 06/30] docs: rewrite piecewise documentation for new API Document the N-variable core formulation with shared lambda weights, explain how the 2-variable case maps to it, and detail the inequality case (auxiliary variable + bound). Remove all references to the removed piecewise() function and descriptor classes. Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/piecewise-linear-constraints.rst | 560 ++++++++++++--------------- 1 file changed, 246 insertions(+), 314 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 9278248a..9996905c 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -7,67 +7,167 @@ Piecewise linear (PWL) constraints approximate nonlinear functions as connected linear segments, allowing you to model cost curves, efficiency curves, or production functions within a linear programming framework. -Use :py:func:`~linopy.piecewise.piecewise` to describe the function and -:py:meth:`~linopy.model.Model.add_piecewise_constraints` to add it to a model. +Use :py:meth:`~linopy.model.Model.add_piecewise_constraints` to add piecewise +constraints to a model. .. contents:: :local: :depth: 2 -Quick Start ------------ + +Overview +-------- + +``add_piecewise_constraints`` supports two calling conventions: + +**N-variable (general form):** Link any number of expressions through shared +breakpoints. All expressions are symmetric — they are jointly constrained to +lie on the interpolated breakpoint curve. .. code-block:: python - import linopy + m.add_piecewise_constraints( + exprs={"power": power, "fuel": fuel, "heat": heat}, + breakpoints=bp, + ) - m = linopy.Model() - x = m.add_variables(name="x", lower=0, upper=100) - y = m.add_variables(name="y") +**2-variable (convenience form):** A shorthand for linking two expressions +``x`` and ``y`` via separate x/y breakpoints. Supports equality and +inequality constraints. - # y equals a piecewise linear function of x - x_pts = linopy.breakpoints([0, 30, 60, 100]) - y_pts = linopy.breakpoints([0, 36, 84, 170]) +.. code-block:: python - m.add_piecewise_constraints(linopy.piecewise(x, x_pts, y_pts) == y) + m.add_piecewise_constraints( + x=power, + y=fuel, + x_points=x_pts, + y_points=y_pts, + ) -The ``piecewise()`` call creates a lazy descriptor. Comparing it with a -variable (``==``, ``<=``, ``>=``) produces a -:class:`~linopy.piecewise.PiecewiseConstraintDescriptor` that -``add_piecewise_constraints`` knows how to process. -.. note:: +Mathematical Background +----------------------- - The ``piecewise(...)`` expression can appear on either side of the - comparison operator. These forms are equivalent:: +Core formulation (N-variable) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - piecewise(x, x_pts, y_pts) == y - y == piecewise(x, x_pts, y_pts) +The general piecewise linear formulation links *N* expressions +:math:`e_1, e_2, \ldots, e_N` through a shared set of breakpoints. +Given :math:`n+1` breakpoints :math:`B_{j,0}, B_{j,1}, \ldots, B_{j,n}` for +each expression :math:`j`, the SOS2 formulation introduces interpolation +weights :math:`\lambda_i \in [0, 1]`: -Formulations ------------- +.. math:: -SOS2 (Convex Combination) + &\sum_{i=0}^{n} \lambda_i = 1 + \qquad \text{(convexity)} + + &e_j = \sum_{i=0}^{n} \lambda_i \, B_{j,i} + \qquad \text{for each expression } j + \qquad \text{(linking)} + + &\text{SOS2}(\lambda_0, \lambda_1, \ldots, \lambda_n) + \qquad \text{(adjacency)} + +The SOS2 constraint ensures at most two *adjacent* :math:`\lambda_i` are +non-zero, so every expression is interpolated within the same segment. All +expressions share the same :math:`\lambda` weights, which is what couples them. + +**Example:** A CHP plant with fuel input, electrical output, and heat output at +four operating points: + +.. code-block:: python + + bp = linopy.breakpoints( + {"fuel": [0, 50, 120, 200], "power": [0, 15, 50, 100], "heat": [0, 25, 45, 55]}, + dim="var", + ) + m.add_piecewise_constraints( + exprs={"fuel": fuel, "power": power, "heat": heat}, + breakpoints=bp, + ) + +At any feasible point, fuel, power, and heat are interpolated between the +*same* pair of adjacent breakpoints. + + +2-variable case: equality ~~~~~~~~~~~~~~~~~~~~~~~~~ -Given breakpoints :math:`b_0, b_1, \ldots, b_n`, the SOS2 formulation -introduces interpolation variables :math:`\lambda_i` such that: +The 2-variable equality constraint :math:`y = f(x)` is the most common use +case. Mathematically, it is equivalent to the N-variable form with two +expressions: + +.. math:: + + x = \sum_i \lambda_i \, x_i, \qquad + y = \sum_i \lambda_i \, y_i, \qquad + \sum_i \lambda_i = 1 + +Internally, the 2-variable equality form builds a dict and delegates to the +same N-variable code path. + +.. code-block:: python + + # These two are equivalent: + m.add_piecewise_constraints(x=x, y=y, x_points=xp, y_points=yp) + + m.add_piecewise_constraints( + exprs={"x": x, "y": y}, + breakpoints=linopy.breakpoints({"x": xp, "y": yp}, dim="var"), + ) + +2-variable case: inequality +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The 2-variable form also supports inequality constraints. This requires +distinct "input" (``x``) and "output" (``y``) roles and is **not available** +in the N-variable form. + +- ``sign="<="`` means :math:`y \le f(x)` — *y* is bounded **above** by the + piecewise function. +- ``sign=">="`` means :math:`y \ge f(x)` — *y* is bounded **below** by the + piecewise function. + +Internally, an auxiliary variable :math:`z` is created that satisfies the +equality :math:`z = f(x)`, then the inequality :math:`y \le z` or +:math:`y \ge z` is added: .. math:: - \lambda_i \in [0, 1], \quad - \sum_{i=0}^{n} \lambda_i = 1, \quad - x = \sum_{i=0}^{n} \lambda_i \, b_i + &z = \sum_i \lambda_i \, y_i, \qquad + x = \sum_i \lambda_i \, x_i + + &y \le z \quad \text{(for sign="<=")} + \qquad \text{or} \qquad + y \ge z \quad \text{(for sign=">=")} -The SOS2 constraint ensures that **at most two adjacent** :math:`\lambda_i` can -be non-zero, so :math:`x` is interpolated within one segment. +.. code-block:: python + + # fuel is bounded above by the piecewise function of power + m.add_piecewise_constraints( + x=power, + y=fuel, + x_points=xp, + y_points=yp, + sign="<=", + ) + + +Formulation Methods +------------------- + +SOS2 (Convex Combination) +~~~~~~~~~~~~~~~~~~~~~~~~~ + +The default formulation, using Special Ordered Sets of type 2. Works for any +breakpoint ordering and both equality and inequality constraints. .. note:: - SOS2 is a combinatorial constraint handled via branch-and-bound, similar to - integer variables. Prefer the incremental method - (``method="incremental"`` or ``method="auto"``) when breakpoints are + SOS2 is a combinatorial constraint handled via branch-and-bound. + Prefer ``method="incremental"`` or ``method="auto"`` when breakpoints are monotonic. Incremental (Delta) Formulation @@ -80,47 +180,41 @@ incremental formulation uses fill-fraction variables: \delta_i \in [0, 1], \quad \delta_{i+1} \le \delta_i, \quad - x = b_0 + \sum_{i=1}^{n} \delta_i \, (b_i - b_{i-1}) + e_j = B_{j,0} + \sum_{i=1}^{n} \delta_i \, (B_{j,i} - B_{j,i-1}) -The filling-order constraints enforce that segment :math:`i+1` cannot be -partially filled unless segment :math:`i` is completely filled. Binary -indicator variables enforce integrality. +Binary indicators enforce segment ordering. This avoids SOS2 constraints +entirely, using only standard MIP constructs. -**Limitation:** Breakpoints must be strictly monotonic. For non-monotonic -curves, use SOS2. +**Limitation:** Breakpoints must be strictly monotonic along the breakpoint +dimension. LP (Tangent-Line) Formulation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ For **inequality** constraints where the function is **convex** (for ``>=``) or **concave** (for ``<=``), a pure LP formulation adds one tangent-line -constraint per segment — no SOS2 or binary variables needed. +constraint per segment: .. math:: - y \le m_k \, x + c_k \quad \text{for each segment } k \text{ (concave case)} + y \le m_k \, x + c_k \quad \text{for each segment } k \text{ (concave, sign="<=")} +No SOS2 or binary variables are needed — this is solvable by any LP solver. Domain bounds :math:`x_{\min} \le x \le x_{\max}` are added automatically. -**Limitation:** Only valid for inequality constraints with the correct -convexity; not valid for equality constraints. +**Limitation:** 2-variable inequality only. Requires correct convexity. Disjunctive (Disaggregated Convex Combination) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -For **disconnected segments** (with gaps), the disjunctive formulation selects -exactly one segment via binary indicators and applies SOS2 within it. No big-M -constants are needed, giving a tight LP relaxation. - -Given :math:`K` segments, each with breakpoints :math:`b_{k,0}, \ldots, b_{k,n_k}`: +For **disconnected segments** (with gaps), binary indicators select exactly one +segment and SOS2 applies within it. No big-M constants are needed. .. math:: - y_k \in \{0, 1\}, \quad \sum_{k} y_k = 1 - - \lambda_{k,i} \in [0, 1], \quad - \sum_{i} \lambda_{k,i} = y_k, \quad - x = \sum_{k} \sum_{i} \lambda_{k,i} \, b_{k,i} + z_k \in \{0, 1\}, \quad \sum_{k} z_k = 1, \quad + \sum_{i} \lambda_{k,i} = z_k, \quad + e_j = \sum_{k} \sum_{i} \lambda_{k,i} \, B_{j,k,i} .. _choosing-a-formulation: @@ -128,10 +222,9 @@ Given :math:`K` segments, each with breakpoints :math:`b_{k,0}, \ldots, b_{k,n_k Choosing a Formulation ~~~~~~~~~~~~~~~~~~~~~~ -Pass ``method="auto"`` (the default) and linopy will pick the best -formulation automatically: +Pass ``method="auto"`` (the default) and linopy picks the best formulation: -- **Equality + monotonic x** → incremental +- **Equality + monotonic breakpoints** → incremental - **Inequality + correct convexity** → LP - Otherwise → SOS2 - Disjunctive (segments) → always SOS2 with binary selection @@ -170,283 +263,150 @@ formulation automatically: - Continuous + binary - Continuous only - Binary + SOS2 - * - Solver support - - SOS2-capable - - MIP-capable - - **Any LP solver** - - SOS2 + MIP + * - N-variable support + - Yes + - Yes + - **No** (2-var only) + - 2-var only -Basic Usage ------------ +Usage Examples +-------------- -Equality constraint +2-variable equality ~~~~~~~~~~~~~~~~~~~ -Link ``y`` to a piecewise linear function of ``x``: - .. code-block:: python - import linopy - - m = linopy.Model() - x = m.add_variables(name="x", lower=0, upper=100) - y = m.add_variables(name="y") - - x_pts = linopy.breakpoints([0, 30, 60, 100]) - y_pts = linopy.breakpoints([0, 36, 84, 170]) - - m.add_piecewise_constraints(linopy.piecewise(x, x_pts, y_pts) == y) - -Inequality constraints -~~~~~~~~~~~~~~~~~~~~~~ + m.add_piecewise_constraints( + x=power, + y=fuel, + x_points=linopy.breakpoints([0, 30, 60, 100]), + y_points=linopy.breakpoints([0, 36, 84, 170]), + ) -Use ``<=`` or ``>=`` to bound ``y`` by the piecewise function: +2-variable inequality +~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python - pw = linopy.piecewise(x, x_pts, y_pts) - - # y must be at most the piecewise function of x (pw >= y ↔ y <= pw) - m.add_piecewise_constraints(pw >= y) + # fuel <= f(power): y bounded above + m.add_piecewise_constraints( + x=power, + y=fuel, + x_points=x_pts, + y_points=y_pts, + sign="<=", + ) - # y must be at least the piecewise function of x (pw <= y ↔ y >= pw) - m.add_piecewise_constraints(pw <= y) + # fuel >= f(power): y bounded below + m.add_piecewise_constraints( + x=power, + y=fuel, + x_points=x_pts, + y_points=y_pts, + sign=">=", + ) -Choosing a method -~~~~~~~~~~~~~~~~~ +N-variable linking +~~~~~~~~~~~~~~~~~~ .. code-block:: python - pw = linopy.piecewise(x, x_pts, y_pts) - - # Explicit SOS2 - m.add_piecewise_constraints(pw == y, method="sos2") - - # Explicit incremental (requires monotonic x_pts) - m.add_piecewise_constraints(pw == y, method="incremental") - - # Explicit LP (requires inequality + correct convexity + increasing x_pts) - m.add_piecewise_constraints(pw >= y, method="lp") - - # Auto-select best method (default) - m.add_piecewise_constraints(pw == y, method="auto") + bp = linopy.breakpoints( + {"power": [0, 30, 60, 100], "fuel": [0, 40, 85, 160], "heat": [0, 25, 55, 95]}, + dim="var", + ) + m.add_piecewise_constraints( + exprs={"power": power, "fuel": fuel, "heat": heat}, + breakpoints=bp, + ) Disjunctive (disconnected segments) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Use :func:`~linopy.piecewise.segments` to define breakpoints with gaps: - .. code-block:: python - m = linopy.Model() - x = m.add_variables(name="x", lower=0, upper=100) - y = m.add_variables(name="y") - - # Two disconnected segments: [0,10] and [50,100] x_seg = linopy.segments([(0, 10), (50, 100)]) y_seg = linopy.segments([(0, 15), (60, 130)]) - m.add_piecewise_constraints(linopy.piecewise(x, x_seg, y_seg) == y) - -The disjunctive formulation is selected automatically when -``x_points`` / ``y_points`` have a segment dimension (created by -:func:`~linopy.piecewise.segments`). - - -Breakpoints Factory -------------------- - -The :func:`~linopy.piecewise.breakpoints` factory creates DataArrays with -the correct ``_breakpoint`` dimension. It accepts several input types -(``BreaksLike``): - -From a list -~~~~~~~~~~~ - -.. code-block:: python - - # 1D breakpoints (dims: [_breakpoint]) - bp = linopy.breakpoints([0, 50, 100]) - -From a pandas Series -~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: python - - import pandas as pd - - bp = linopy.breakpoints(pd.Series([0, 50, 100])) - -From a DataFrame (per-entity, requires ``dim``) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: python - - # rows = entities, columns = breakpoints - df = pd.DataFrame( - {"bp0": [0, 0], "bp1": [50, 80], "bp2": [100, float("nan")]}, - index=["gen1", "gen2"], + m.add_piecewise_constraints( + x=x, + y=y, + x_points=x_seg, + y_points=y_seg, ) - bp = linopy.breakpoints(df, dim="generator") -From a dict (per-entity, ragged lengths allowed) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Choosing a method +~~~~~~~~~~~~~~~~~ .. code-block:: python - # NaN-padded to the longest entry - bp = linopy.breakpoints( - {"gen1": [0, 50, 100], "gen2": [0, 80]}, - dim="generator", + m.add_piecewise_constraints(x=x, y=y, x_points=xp, y_points=yp, method="sos2") + m.add_piecewise_constraints( + x=x, y=y, x_points=xp, y_points=yp, method="incremental" ) + m.add_piecewise_constraints( + x=x, y=y, x_points=xp, y_points=yp, sign="<=", method="lp" + ) + m.add_piecewise_constraints( + x=x, y=y, x_points=xp, y_points=yp, method="auto" + ) # default -From a DataArray (pass-through) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: python - - import xarray as xr - - arr = xr.DataArray([0, 50, 100], dims=["_breakpoint"]) - bp = linopy.breakpoints(arr) # returned as-is - -Slopes mode -~~~~~~~~~~~ +Active parameter (unit commitment) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Compute y-breakpoints from segment slopes and an initial y-value: +The ``active`` parameter gates the piecewise function with a binary variable. +When ``active=0``, all auxiliary variables are forced to zero. .. code-block:: python - y_pts = linopy.breakpoints( - slopes=[1.2, 1.4, 1.7], - x_points=[0, 30, 60, 100], - y0=0, + commit = m.add_variables(name="commit", binary=True, coords=[time]) + m.add_piecewise_constraints( + x=power, + y=fuel, + x_points=x_pts, + y_points=y_pts, + active=commit, ) - # Equivalent to breakpoints([0, 36, 78, 146]) - - -Segments Factory ----------------- - -The :func:`~linopy.piecewise.segments` factory creates DataArrays with both -``_segment`` and ``_breakpoint`` dimensions (``SegmentsLike``): - -From a list of sequences -~~~~~~~~~~~~~~~~~~~~~~~~ -.. code-block:: python - # dims: [_segment, _breakpoint] - seg = linopy.segments([(0, 10), (50, 100)]) +Breakpoints and Segments Factories +----------------------------------- -From a dict (per-entity) -~~~~~~~~~~~~~~~~~~~~~~~~~ +:func:`~linopy.piecewise.breakpoints` creates DataArrays with the correct +``_breakpoint`` dimension. Accepts lists, Series, DataFrames, dicts, or +DataArrays: .. code-block:: python - seg = linopy.segments( - {"gen1": [(0, 10), (50, 100)], "gen2": [(0, 80)]}, - dim="generator", - ) + linopy.breakpoints([0, 50, 100]) # from list + linopy.breakpoints({"gen1": [0, 50], "gen2": [0, 80]}, dim="gen") # per-entity + linopy.breakpoints(slopes=[1.2, 1.4], x_points=[0, 30, 60], y0=0) # from slopes -From a DataFrame -~~~~~~~~~~~~~~~~ +:func:`~linopy.piecewise.segments` creates DataArrays with both ``_segment`` +and ``_breakpoint`` dimensions for disjunctive formulations: .. code-block:: python - # rows = segments, columns = breakpoints - seg = linopy.segments(pd.DataFrame([[0, 10], [50, 100]])) + linopy.segments([(0, 10), (50, 100)]) # from list + linopy.segments({"gen1": [(0, 10)], "gen2": [(0, 80)]}, dim="gen") # per-entity Auto-broadcasting ----------------- -Breakpoints are automatically broadcast to match the dimensions of the -expressions. You don't need ``expand_dims`` when your variables have extra -dimensions (e.g. ``time``): +Breakpoints are automatically broadcast to match expression dimensions. +You don't need ``expand_dims`` when your variables have extra dimensions: .. code-block:: python - import pandas as pd - import linopy - - m = linopy.Model() time = pd.Index([1, 2, 3], name="time") x = m.add_variables(name="x", lower=0, upper=100, coords=[time]) y = m.add_variables(name="y", coords=[time]) # 1D breakpoints auto-expand to match x's time dimension - x_pts = linopy.breakpoints([0, 50, 100]) - y_pts = linopy.breakpoints([0, 70, 150]) - m.add_piecewise_constraints(linopy.piecewise(x, x_pts, y_pts) == y) - - -Method Signatures ------------------ - -``piecewise`` -~~~~~~~~~~~~~ - -.. code-block:: python - - linopy.piecewise(expr, x_points, y_points) - -- ``expr`` -- ``Variable`` or ``LinearExpression``. The "x" side expression. -- ``x_points`` -- ``BreaksLike``. Breakpoint x-coordinates. -- ``y_points`` -- ``BreaksLike``. Breakpoint y-coordinates. - -Returns a :class:`~linopy.piecewise.PiecewiseExpression` that supports -``==``, ``<=``, ``>=`` comparison with another expression. - -``add_piecewise_constraints`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: python - - Model.add_piecewise_constraints( - descriptor, - method="auto", - name=None, - skip_nan_check=False, - ) - -- ``descriptor`` -- :class:`~linopy.piecewise.PiecewiseConstraintDescriptor`. - Created by comparing a ``PiecewiseExpression`` with an expression, e.g. - ``piecewise(x, x_pts, y_pts) == y``. -- ``method`` -- ``"auto"`` (default), ``"sos2"``, ``"incremental"``, or ``"lp"``. -- ``name`` -- ``str``, optional. Base name for generated variables/constraints. -- ``skip_nan_check`` -- ``bool``, default ``False``. - -Returns a :class:`~linopy.constraints.Constraint`, but the returned object is -formulation-dependent: typically ``{name}_convex`` (SOS2), ``{name}_fill`` or -``{name}_y_link`` (incremental), and ``{name}_select`` (disjunctive). For -inequality constraints, the returned constraint is the core piecewise -formulation constraint, not ``{name}_ineq``. - -``breakpoints`` -~~~~~~~~~~~~~~~~ - -.. code-block:: python - - linopy.breakpoints(values, dim=None) - linopy.breakpoints(slopes, x_points, y0, dim=None) - -- ``values`` -- ``BreaksLike`` (list, Series, DataFrame, DataArray, or dict). -- ``slopes``, ``x_points``, ``y0`` -- for slopes mode (mutually exclusive with - ``values``). -- ``dim`` -- ``str``, required when ``values`` or ``slopes`` is a DataFrame or dict. - -``segments`` -~~~~~~~~~~~~~ - -.. code-block:: python - - linopy.segments(values, dim=None) - -- ``values`` -- ``SegmentsLike`` (list of sequences, DataFrame, DataArray, or - dict). -- ``dim`` -- ``str``, required when ``values`` is a dict. + m.add_piecewise_constraints(x=x, y=y, x_points=[0, 50, 100], y_points=[0, 70, 150]) Generated Variables and Constraints @@ -471,16 +431,13 @@ Given base name ``name``, the following objects are created: - :math:`\sum_i \lambda_i = 1`. * - ``{name}_x_link`` - Constraint - - :math:`x = \sum_i \lambda_i \, x_i`. - * - ``{name}_y_link`` - - Constraint - - :math:`y = \sum_i \lambda_i \, y_i`. + - Linking: :math:`e_j = \sum_i \lambda_i \, B_{j,i}` for all expressions. * - ``{name}_aux`` - Variable - - Auxiliary variable :math:`z` (inequality constraints only). + - Auxiliary variable :math:`z` (2-var inequality only). * - ``{name}_ineq`` - Constraint - - :math:`y \le z` or :math:`y \ge z` (inequality only). + - :math:`y \le z` or :math:`y \ge z` (2-var inequality only). **Incremental method:** @@ -497,29 +454,14 @@ Given base name ``name``, the following objects are created: * - ``{name}_inc_binary`` - Variable - Binary indicators for each segment. - * - ``{name}_inc_link`` - - Constraint - - :math:`\delta_i \le y_i` (delta bounded by binary). * - ``{name}_fill`` - Constraint - - :math:`\delta_{i+1} \le \delta_i` (fill order, 3+ breakpoints). - * - ``{name}_inc_order`` - - Constraint - - :math:`y_{i+1} \le \delta_i` (binary ordering, 3+ breakpoints). + - :math:`\delta_{i+1} \le \delta_i` (fill order). * - ``{name}_x_link`` - Constraint - - :math:`x = x_0 + \sum_i \delta_i \, \Delta x_i`. - * - ``{name}_y_link`` - - Constraint - - :math:`y = y_0 + \sum_i \delta_i \, \Delta y_i`. - * - ``{name}_aux`` - - Variable - - Auxiliary variable :math:`z` (inequality constraints only). - * - ``{name}_ineq`` - - Constraint - - :math:`y \le z` or :math:`y \ge z` (inequality only). + - Linking: :math:`e_j = B_{j,0} + \sum_i \delta_i \, \Delta B_{j,i}`. -**LP method:** +**LP method (2-var inequality only):** .. list-table:: :header-rows: 1 @@ -549,33 +491,23 @@ Given base name ``name``, the following objects are created: - Description * - ``{name}_binary`` - Variable - - Segment indicators :math:`y_k \in \{0, 1\}`. + - Segment indicators :math:`z_k \in \{0, 1\}`. * - ``{name}_select`` - Constraint - - :math:`\sum_k y_k = 1`. + - :math:`\sum_k z_k = 1`. * - ``{name}_lambda`` - Variable - Per-segment interpolation weights (SOS2). * - ``{name}_convex`` - Constraint - - :math:`\sum_i \lambda_{k,i} = y_k`. + - :math:`\sum_i \lambda_{k,i} = z_k`. * - ``{name}_x_link`` - Constraint - - :math:`x = \sum_k \sum_i \lambda_{k,i} \, x_{k,i}`. - * - ``{name}_y_link`` - - Constraint - - :math:`y = \sum_k \sum_i \lambda_{k,i} \, y_{k,i}`. - * - ``{name}_aux`` - - Variable - - Auxiliary variable :math:`z` (inequality constraints only). - * - ``{name}_ineq`` - - Constraint - - :math:`y \le z` or :math:`y \ge z` (inequality only). + - :math:`e_j = \sum_k \sum_i \lambda_{k,i} \, B_{j,k,i}`. + See Also -------- -- :doc:`piecewise-linear-constraints-tutorial` -- Worked examples covering SOS2, incremental, LP, and disjunctive usage +- :doc:`piecewise-linear-constraints-tutorial` -- Worked examples (notebook) - :doc:`sos-constraints` -- Low-level SOS1/SOS2 constraint API -- :doc:`creating-constraints` -- General constraint creation -- :doc:`user-guide` -- Overall linopy usage patterns From e219c47eef085cee5cc9c48dccacad9b3e188b56 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:09:47 +0200 Subject: [PATCH 07/30] refac: extract piecewise_envelope, remove sign from piecewise API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add linopy.piecewise_envelope() as a standalone linearization utility that returns tangent-line LinearExpressions — no auxiliary variables. Users combine it with regular add_constraints for inequality bounds. Remove sign parameter, LP method, convexity detection, and all inequality logic from add_piecewise_constraints. The piecewise API now only does equality linking (the core formulation). Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/piecewise-linear-constraints.rst | 156 ++----- examples/piecewise-linear-constraints.ipynb | 42 +- linopy/__init__.py | 2 + linopy/linearization.py | 99 +++++ linopy/piecewise.py | 298 ++----------- test/test_piecewise_constraints.py | 449 +++----------------- 6 files changed, 237 insertions(+), 809 deletions(-) create mode 100644 linopy/linearization.py diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 9996905c..9b7bafed 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -8,7 +8,8 @@ linear segments, allowing you to model cost curves, efficiency curves, or production functions within a linear programming framework. Use :py:meth:`~linopy.model.Model.add_piecewise_constraints` to add piecewise -constraints to a model. +equality constraints to a model. For inequality constraints (upper/lower +envelopes), use :func:`~linopy.linearization.piecewise_envelope`. .. contents:: :local: @@ -21,7 +22,7 @@ Overview ``add_piecewise_constraints`` supports two calling conventions: **N-variable (general form):** Link any number of expressions through shared -breakpoints. All expressions are symmetric — they are jointly constrained to +breakpoints. All expressions are symmetric --- they are jointly constrained to lie on the interpolated breakpoint curve. .. code-block:: python @@ -32,8 +33,7 @@ lie on the interpolated breakpoint curve. ) **2-variable (convenience form):** A shorthand for linking two expressions -``x`` and ``y`` via separate x/y breakpoints. Supports equality and -inequality constraints. +``x`` and ``y`` via separate x/y breakpoints. .. code-block:: python @@ -44,6 +44,17 @@ inequality constraints. y_points=y_pts, ) +**Envelope (inequality):** For inequality constraints such as +:math:`y \le f(x)` or :math:`y \ge f(x)`, use +:func:`~linopy.linearization.piecewise_envelope` to obtain tangent-line +expressions and add them as regular constraints: + +.. code-block:: python + + envelope = linopy.piecewise_envelope(power, x_pts, y_pts) + m.add_constraints(fuel <= envelope) # upper bound (concave f) + m.add_constraints(fuel >= envelope) # lower bound (convex f) + Mathematical Background ----------------------- @@ -118,41 +129,27 @@ same N-variable code path. breakpoints=linopy.breakpoints({"x": xp, "y": yp}, dim="var"), ) -2-variable case: inequality -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The 2-variable form also supports inequality constraints. This requires -distinct "input" (``x``) and "output" (``y``) roles and is **not available** -in the N-variable form. -- ``sign="<="`` means :math:`y \le f(x)` — *y* is bounded **above** by the - piecewise function. -- ``sign=">="`` means :math:`y \ge f(x)` — *y* is bounded **below** by the - piecewise function. +Piecewise Envelope (inequality) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Internally, an auxiliary variable :math:`z` is created that satisfies the -equality :math:`z = f(x)`, then the inequality :math:`y \le z` or -:math:`y \ge z` is added: +For inequality constraints, use :func:`~linopy.linearization.piecewise_envelope` +instead of ``add_piecewise_constraints``. The envelope function computes +tangent-line expressions for each segment --- no auxiliary variables are created: .. math:: - &z = \sum_i \lambda_i \, y_i, \qquad - x = \sum_i \lambda_i \, x_i + \text{tangent}_k(x) = m_k \cdot x + c_k \quad \text{for each segment } k - &y \le z \quad \text{(for sign="<=")} - \qquad \text{or} \qquad - y \ge z \quad \text{(for sign=">=")} +Use the result in a regular constraint: .. code-block:: python - # fuel is bounded above by the piecewise function of power - m.add_piecewise_constraints( - x=power, - y=fuel, - x_points=xp, - y_points=yp, - sign="<=", - ) + envelope = linopy.piecewise_envelope(power, x_pts, y_pts) + m.add_constraints(fuel <= envelope) # upper bound (concave f) + m.add_constraints(fuel >= envelope) # lower bound (convex f) + +This is solvable by any LP solver --- no SOS2 or binary variables needed. Formulation Methods @@ -162,7 +159,7 @@ SOS2 (Convex Combination) ~~~~~~~~~~~~~~~~~~~~~~~~~ The default formulation, using Special Ordered Sets of type 2. Works for any -breakpoint ordering and both equality and inequality constraints. +breakpoint ordering. .. note:: @@ -188,22 +185,6 @@ entirely, using only standard MIP constructs. **Limitation:** Breakpoints must be strictly monotonic along the breakpoint dimension. -LP (Tangent-Line) Formulation -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -For **inequality** constraints where the function is **convex** (for ``>=``) -or **concave** (for ``<=``), a pure LP formulation adds one tangent-line -constraint per segment: - -.. math:: - - y \le m_k \, x + c_k \quad \text{for each segment } k \text{ (concave, sign="<=")} - -No SOS2 or binary variables are needed — this is solvable by any LP solver. -Domain bounds :math:`x_{\min} \le x \le x_{\max}` are added automatically. - -**Limitation:** 2-variable inequality only. Requires correct convexity. - Disjunctive (Disaggregated Convex Combination) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -224,49 +205,38 @@ Choosing a Formulation Pass ``method="auto"`` (the default) and linopy picks the best formulation: -- **Equality + monotonic breakpoints** → incremental -- **Inequality + correct convexity** → LP -- Otherwise → SOS2 -- Disjunctive (segments) → always SOS2 with binary selection +- **Equality + monotonic breakpoints** -> incremental +- Otherwise -> SOS2 +- Disjunctive (segments) -> always SOS2 with binary selection +- **Inequality** -> use ``piecewise_envelope`` + regular constraints .. list-table:: :header-rows: 1 - :widths: 25 20 20 15 20 + :widths: 25 20 20 20 * - Property - SOS2 - Incremental - - LP - Disjunctive * - Segments - - Connected - Connected - Connected - Disconnected * - Constraint type - - ``==``, ``<=``, ``>=`` - - ``==``, ``<=``, ``>=`` - - ``<=``, ``>=`` only - - ``==``, ``<=``, ``>=`` + - Equality + - Equality + - Equality * - Breakpoint order - Any - Strictly monotonic - - Strictly increasing - Any (per segment) - * - Convexity requirement - - None - - None - - Concave (≤) or convex (≥) - - None * - Variable types - Continuous + SOS2 - Continuous + binary - - Continuous only - Binary + SOS2 * - N-variable support - Yes - Yes - - **No** (2-var only) - 2-var only @@ -285,28 +255,18 @@ Usage Examples y_points=linopy.breakpoints([0, 36, 84, 170]), ) -2-variable inequality -~~~~~~~~~~~~~~~~~~~~~ +Inequality via envelope +~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python - # fuel <= f(power): y bounded above - m.add_piecewise_constraints( - x=power, - y=fuel, - x_points=x_pts, - y_points=y_pts, - sign="<=", - ) + # fuel <= f(power): y bounded above (concave function) + envelope = linopy.piecewise_envelope(power, x_pts, y_pts) + m.add_constraints(fuel <= envelope) - # fuel >= f(power): y bounded below - m.add_piecewise_constraints( - x=power, - y=fuel, - x_points=x_pts, - y_points=y_pts, - sign=">=", - ) + # fuel >= f(power): y bounded below (convex function) + envelope = linopy.piecewise_envelope(power, x_pts, y_pts) + m.add_constraints(fuel >= envelope) N-variable linking ~~~~~~~~~~~~~~~~~~ @@ -346,9 +306,6 @@ Choosing a method m.add_piecewise_constraints( x=x, y=y, x_points=xp, y_points=yp, method="incremental" ) - m.add_piecewise_constraints( - x=x, y=y, x_points=xp, y_points=yp, sign="<=", method="lp" - ) m.add_piecewise_constraints( x=x, y=y, x_points=xp, y_points=yp, method="auto" ) # default @@ -432,12 +389,6 @@ Given base name ``name``, the following objects are created: * - ``{name}_x_link`` - Constraint - Linking: :math:`e_j = \sum_i \lambda_i \, B_{j,i}` for all expressions. - * - ``{name}_aux`` - - Variable - - Auxiliary variable :math:`z` (2-var inequality only). - * - ``{name}_ineq`` - - Constraint - - :math:`y \le z` or :math:`y \ge z` (2-var inequality only). **Incremental method:** @@ -461,25 +412,6 @@ Given base name ``name``, the following objects are created: - Constraint - Linking: :math:`e_j = B_{j,0} + \sum_i \delta_i \, \Delta B_{j,i}`. -**LP method (2-var inequality only):** - -.. list-table:: - :header-rows: 1 - :widths: 30 15 55 - - * - Name - - Type - - Description - * - ``{name}_lp`` - - Constraint - - Tangent-line constraints (one per segment). - * - ``{name}_lp_domain_lo`` - - Constraint - - :math:`x \ge x_{\min}`. - * - ``{name}_lp_domain_hi`` - - Constraint - - :math:`x \le x_{\max}`. - **Disjunctive method:** .. list-table:: diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index bfc87c20..29f721b9 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| 6 | CHP plant (N-variable) | Joint power/fuel/heat | N-variable SOS2 |\n\n**API:**\n- **2-variable:** `m.add_piecewise_constraints(x=power, y=fuel, x_points=xp, y_points=yp)`\n- **N-variable:** `m.add_piecewise_constraints(exprs={...}, breakpoints=bp)`" + "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 | Envelope |\n| 5 | Gas unit with commitment | On/off + min load | Incremental + `active` |\n| 6 | CHP plant (N-variable) | Joint power/fuel/heat | N-variable SOS2 |\n\n**API:**\n- **2-variable:** `m.add_piecewise_constraints(x=power, y=fuel, x_points=xp, y_points=yp)`\n- **N-variable:** `m.add_piecewise_constraints(exprs={...}, breakpoints=bp)`\n- **Envelope (inequality):** `linopy.piecewise_envelope(x, x_pts, y_pts)` + regular constraints" }, { "cell_type": "code", @@ -504,19 +504,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "## 4. LP formulation — 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", - "binary variables needed. This is the fastest to solve.\n", - "\n", - "For this formulation, the x-breakpoints must be in **strictly increasing**\n", - "order.\n", - "\n", - "Here we bound fuel consumption *below* a concave efficiency envelope.\n" - ] + "source": "## 4. Envelope formulation — Concave efficiency bound\n\nWhen the piecewise function is **concave** and we want to bound y **above**\n(i.e. `y <= f(x)`), we can use `piecewise_envelope` to get tangent-line\nexpressions and add them as regular constraints — no SOS2 or binary\nvariables needed. This is the fastest to solve.\n\nHere we bound fuel consumption *below* a concave efficiency envelope." }, { "cell_type": "code", @@ -535,31 +523,7 @@ } }, "outputs": [], - "source": [ - "x_pts4 = linopy.breakpoints([0, 40, 80, 120])\n", - "# Concave curve: decreasing marginal fuel per MW\n", - "y_pts4 = linopy.breakpoints([0, 50, 90, 120])\n", - "\n", - "m4 = linopy.Model()\n", - "\n", - "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", - "# sign=\"<=\" means: fuel <= f(power) — y is bounded above by the piecewise function\n", - "m4.add_piecewise_constraints(\n", - " x=power,\n", - " y=fuel,\n", - " x_points=x_pts4,\n", - " y_points=y_pts4,\n", - " sign=\"<=\",\n", - " name=\"pwl\",\n", - ")\n", - "\n", - "demand4 = xr.DataArray([30, 80, 100], coords=[time])\n", - "m4.add_constraints(power == demand4, name=\"demand\")\n", - "# Maximize fuel (to push against the upper bound)\n", - "m4.add_objective(-fuel.sum())" - ] + "source": "x_pts4 = linopy.breakpoints([0, 40, 80, 120])\n# Concave curve: decreasing marginal fuel per MW\ny_pts4 = linopy.breakpoints([0, 50, 90, 120])\n\nm4 = linopy.Model()\n\npower = m4.add_variables(name=\"power\", lower=0, upper=120, coords=[time])\nfuel = m4.add_variables(name=\"fuel\", lower=0, coords=[time])\n\n# Use piecewise_envelope to get tangent-line expressions, then add as <= constraint\nenvelope = linopy.piecewise_envelope(power, x_pts4, y_pts4)\nm4.add_constraints(fuel <= envelope, name=\"pwl\")\n\ndemand4 = xr.DataArray([30, 80, 100], coords=[time])\nm4.add_constraints(power == demand4, name=\"demand\")\n# Maximize fuel (to push against the upper bound)\nm4.add_objective(-fuel.sum())" }, { "cell_type": "code", diff --git a/linopy/__init__.py b/linopy/__init__.py index 498c9e12..16d9f1cd 100644 --- a/linopy/__init__.py +++ b/linopy/__init__.py @@ -18,6 +18,7 @@ from linopy.constraints import Constraint, Constraints from linopy.expressions import LinearExpression, QuadraticExpression, merge from linopy.io import read_netcdf +from linopy.linearization import piecewise_envelope from linopy.model import Model, Variable, Variables, available_solvers from linopy.objective import Objective from linopy.piecewise import breakpoints, segments, slopes_to_points @@ -44,6 +45,7 @@ "Variables", "available_solvers", "breakpoints", + "piecewise_envelope", "segments", "slopes_to_points", "align", diff --git a/linopy/linearization.py b/linopy/linearization.py new file mode 100644 index 00000000..369325d9 --- /dev/null +++ b/linopy/linearization.py @@ -0,0 +1,99 @@ +""" +Linearization utilities for approximating nonlinear functions. + +These helpers return regular :class:`~linopy.expressions.LinearExpression` +objects --- no auxiliary variables or special constraint types are created. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from xarray import DataArray + +from linopy.constants import BREAKPOINT_DIM, LP_SEG_DIM +from linopy.piecewise import BreaksLike, _coerce_breaks + +if TYPE_CHECKING: + from linopy.expressions import LinearExpression + from linopy.types import LinExprLike + + +def piecewise_envelope( + x: LinExprLike, + x_points: BreaksLike, + y_points: BreaksLike, +) -> LinearExpression: + r""" + Compute tangent-line expressions for a piecewise linear function. + + Returns a :class:`~linopy.expressions.LinearExpression` with an extra + segment dimension. Each element along the segment dimension is the + tangent line of one segment: :math:`m_k \cdot x + c_k`. + + Use the result in a regular constraint to create an upper or lower + envelope: + + .. code-block:: python + + envelope = piecewise_envelope(power, x_pts, y_pts) + m.add_constraints(fuel <= envelope) # upper bound (concave f) + m.add_constraints(fuel >= envelope) # lower bound (convex f) + + No auxiliary variables are created --- the result is purely linear. + + Parameters + ---------- + x : Variable or LinearExpression + The input expression. + x_points : BreaksLike + Breakpoint x-coordinates (must be strictly increasing). + y_points : BreaksLike + Breakpoint y-coordinates. + + Returns + ------- + LinearExpression + Expression with an additional ``_breakpoint_seg`` dimension + (one entry per segment). + """ + from linopy.expressions import LinearExpression + from linopy.variables import Variable + + if not isinstance(x_points, DataArray): + x_points = _coerce_breaks(x_points) + if not isinstance(y_points, DataArray): + y_points = _coerce_breaks(y_points) + + dx = x_points.diff(BREAKPOINT_DIM) + dy = y_points.diff(BREAKPOINT_DIM) + slopes = dy / dx + + n_seg = slopes.sizes[BREAKPOINT_DIM] + seg_index = np.arange(n_seg) + + slopes = slopes.rename({BREAKPOINT_DIM: LP_SEG_DIM}) + slopes[LP_SEG_DIM] = seg_index + + x_base = x_points.isel({BREAKPOINT_DIM: slice(None, -1)}).rename( + {BREAKPOINT_DIM: LP_SEG_DIM} + ) + y_base = y_points.isel({BREAKPOINT_DIM: slice(None, -1)}).rename( + {BREAKPOINT_DIM: LP_SEG_DIM} + ) + x_base[LP_SEG_DIM] = seg_index + y_base[LP_SEG_DIM] = seg_index + + # tangent_k(x) = slopes_k * (x - x_base_k) + y_base_k + # = slopes_k * x + (y_base_k - slopes_k * x_base_k) + intercepts = y_base - slopes * x_base + + if isinstance(x, Variable): + x_expr = x.to_linexpr() + elif isinstance(x, LinearExpression): + x_expr = x + else: + raise TypeError(f"x must be a Variable or LinearExpression, got {type(x)}") + + return slopes * x_expr + intercepts diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 4a9fbd9e..d08d1bcf 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -21,7 +21,6 @@ HELPER_DIMS, LP_SEG_DIM, PWL_ACTIVE_BOUND_SUFFIX, - PWL_AUX_SUFFIX, PWL_BINARY_SUFFIX, PWL_CONVEX_SUFFIX, PWL_DELTA_SUFFIX, @@ -30,8 +29,6 @@ PWL_INC_LINK_SUFFIX, PWL_INC_ORDER_SUFFIX, PWL_LAMBDA_SUFFIX, - PWL_LP_DOMAIN_SUFFIX, - PWL_LP_SUFFIX, PWL_SELECT_SUFFIX, PWL_X_LINK_SUFFIX, PWL_Y_LINK_SUFFIX, @@ -425,15 +422,6 @@ def _check_strict_monotonicity(bp: DataArray) -> bool: return bool(monotonic.all()) -def _check_strict_increasing(bp: DataArray) -> bool: - """Check if breakpoints are strictly increasing along BREAKPOINT_DIM.""" - diffs = bp.diff(BREAKPOINT_DIM) - pos = (diffs > 0) | diffs.isnull() - has_non_nan = (~diffs.isnull()).any(BREAKPOINT_DIM) - increasing = pos.all(BREAKPOINT_DIM) & has_non_nan - return bool(increasing.all()) - - def _has_trailing_nan_only(bp: DataArray) -> bool: """Check that NaN values only appear as trailing entries along BREAKPOINT_DIM.""" valid = ~bp.isnull() @@ -506,101 +494,6 @@ def _compute_combined_mask( return ~(x_points.isnull() | y_points.isnull()) -def _detect_convexity( - x_points: DataArray, - y_points: DataArray, -) -> Literal["convex", "concave", "linear", "mixed"]: - """ - Detect convexity of the piecewise function. - - Requires strictly increasing x breakpoints and computes slopes and - second differences in the given order. - """ - if not _check_strict_increasing(x_points): - raise ValueError( - "Convexity detection requires strictly increasing x_points. " - "Pass breakpoints in increasing x-order or use method='sos2'." - ) - - dx = x_points.diff(BREAKPOINT_DIM) - dy = y_points.diff(BREAKPOINT_DIM) - - valid = ~(dx.isnull() | dy.isnull() | (dx == 0)) - slopes = dy / dx - - if slopes.sizes[BREAKPOINT_DIM] < 2: - return "linear" - - slope_diffs = slopes.diff(BREAKPOINT_DIM) - - valid_diffs = valid.isel({BREAKPOINT_DIM: slice(None, -1)}) - valid_diffs_hi = valid.isel({BREAKPOINT_DIM: slice(1, None)}) - valid_diffs_combined = valid_diffs.values & valid_diffs_hi.values - - sd_values = slope_diffs.values - if valid_diffs_combined.size == 0 or not valid_diffs_combined.any(): - return "linear" - - valid_sd = sd_values[valid_diffs_combined] - all_nonneg = bool(np.all(valid_sd >= -1e-10)) - all_nonpos = bool(np.all(valid_sd <= 1e-10)) - - if all_nonneg and all_nonpos: - return "linear" - if all_nonneg: - return "convex" - if all_nonpos: - return "concave" - return "mixed" - - -# --------------------------------------------------------------------------- -# Internal formulation functions -# --------------------------------------------------------------------------- - - -def _add_pwl_lp( - model: Model, - name: str, - x_expr: LinearExpression, - y_expr: LinearExpression, - sign: str, - x_points: DataArray, - y_points: DataArray, -) -> Constraint: - """Add pure LP tangent-line constraints.""" - dx = x_points.diff(BREAKPOINT_DIM) - dy = y_points.diff(BREAKPOINT_DIM) - slopes = dy / dx - - slopes = slopes.rename({BREAKPOINT_DIM: LP_SEG_DIM}) - n_seg = slopes.sizes[LP_SEG_DIM] - slopes[LP_SEG_DIM] = np.arange(n_seg) - - x_base = x_points.isel({BREAKPOINT_DIM: slice(None, -1)}) - y_base = y_points.isel({BREAKPOINT_DIM: slice(None, -1)}) - x_base = x_base.rename({BREAKPOINT_DIM: LP_SEG_DIM}) - y_base = y_base.rename({BREAKPOINT_DIM: LP_SEG_DIM}) - x_base[LP_SEG_DIM] = np.arange(n_seg) - y_base[LP_SEG_DIM] = np.arange(n_seg) - - rhs = y_base - slopes * x_base - lhs = y_expr - slopes * x_expr - - if sign == "<=": - con = model.add_constraints(lhs <= rhs, name=f"{name}{PWL_LP_SUFFIX}") - else: - con = model.add_constraints(lhs >= rhs, name=f"{name}{PWL_LP_SUFFIX}") - - # Domain bound constraints to keep x within [x_min, x_max] - x_lo = x_points.min(dim=BREAKPOINT_DIM) - x_hi = x_points.max(dim=BREAKPOINT_DIM) - model.add_constraints(x_expr >= x_lo, name=f"{name}{PWL_LP_DOMAIN_SUFFIX}_lo") - model.add_constraints(x_expr <= x_hi, name=f"{name}{PWL_LP_DOMAIN_SUFFIX}_hi") - - return con - - def _add_pwl_sos2_core( model: Model, name: str, @@ -836,19 +729,18 @@ def add_piecewise_constraints( y: LinExprLike | None = None, x_points: BreaksLike | None = None, y_points: BreaksLike | None = None, - sign: str = "==", active: LinExprLike | None = None, mask: DataArray | None = None, - method: Literal["sos2", "incremental", "auto", "lp"] = "auto", + method: Literal["sos2", "incremental", "auto"] = "auto", name: str | None = None, skip_nan_check: bool = False, ) -> Constraint: r""" - Add piecewise linear constraints. + Add piecewise linear equality constraints. Supports two calling conventions: - **N-variable — link N expressions through shared breakpoints:** + **N-variable --- link N expressions through shared breakpoints:** All expressions are symmetric and linked via shared SOS2 lambda (or incremental delta) weights. Mathematically, each expression is @@ -859,10 +751,10 @@ def add_piecewise_constraints( breakpoints=bp, ) - **2-variable convenience — link x and y via separate breakpoints:** + **2-variable convenience --- link x and y via separate breakpoints:** - A shorthand that builds the N-variable dict internally. When - ``sign="=="`` (the default), the constraint is:: + A shorthand that builds the N-variable dict internally. The + constraint is:: y = f(x) @@ -870,18 +762,9 @@ def add_piecewise_constraints( This is mathematically equivalent to the N-variable form with two expressions. - When ``sign`` is ``"<="`` or ``">="``, the constraint becomes an - *inequality*: - - - ``sign="<="`` means :math:`y \le f(x)` — *y* is bounded **above** - by the piecewise function. - - ``sign=">="`` means :math:`y \ge f(x)` — *y* is bounded **below** - by the piecewise function. - - Inequality constraints introduce an auxiliary variable *z* that - satisfies the equality *z = f(x)*, then adds *y ≤ z* or *y ≥ z*. - This is a 2-variable-only feature because it requires distinct - "input" (*x*) and "output" (*y*) roles. + For inequality constraints (y <= f(x) or y >= f(x)), use + :func:`~linopy.linearization.piecewise_envelope` with regular + ``add_constraints`` instead. Example:: @@ -906,20 +789,14 @@ def add_piecewise_constraints( Breakpoint x-coordinates (2-variable case). y_points : BreaksLike Breakpoint y-coordinates (2-variable case). - sign : {"==", "<=", ">="}, default "==" - Constraint sign (2-variable case only). ``"=="`` constrains - *y = f(x)*. ``"<="`` constrains *y ≤ f(x)*. ``">="`` - constrains *y ≥ f(x)*. Ignored for the N-variable case - (always equality). active : Variable or LinearExpression, optional Binary variable that gates the piecewise function. When ``active=0``, all auxiliary variables (and thus *x* and *y*) are forced to zero. 2-variable case only. mask : DataArray, optional Boolean mask for valid constraints. - method : {"auto", "sos2", "incremental", "lp"}, default "auto" - Formulation method. ``"lp"`` is only available for the - 2-variable inequality case. + method : {"auto", "sos2", "incremental"}, default "auto" + Formulation method. name : str, optional Base name for generated variables/constraints. skip_nan_check : bool, default False @@ -930,16 +807,11 @@ def add_piecewise_constraints( Constraint """ if exprs is not None: - # ── N-variable path ────────────────────────────────────────── + # -- N-variable path -- if breakpoints is None: raise TypeError( "N-variable call requires both 'exprs' and 'breakpoints' keywords." ) - if method == "lp": - raise ValueError( - "Pure LP method is not supported for N-variable piecewise " - "constraints. Use method='sos2' or method='incremental'." - ) return _add_piecewise_nvar( model, exprs=dict(exprs), @@ -950,7 +822,7 @@ def add_piecewise_constraints( skip_nan_check=skip_nan_check, ) - # ── 2-variable convenience path ────────────────────────────────── + # -- 2-variable convenience path -- if x is None or y is None or x_points is None or y_points is None: raise TypeError( "add_piecewise_constraints() requires either:\n" @@ -963,7 +835,6 @@ def add_piecewise_constraints( y=y, x_points=x_points, y_points=y_points, - sign=sign, method=method, active=active, name=name, @@ -977,16 +848,15 @@ def _add_piecewise_2var( y: LinExprLike, x_points: BreaksLike, y_points: BreaksLike, - sign: str = "==", method: str = "auto", active: LinExprLike | None = None, name: str | None = None, skip_nan_check: bool = False, ) -> Constraint: - """2-variable piecewise constraint: y sign f(x).""" - if method not in ("sos2", "incremental", "auto", "lp"): + """2-variable piecewise equality constraint: y = f(x).""" + if method not in ("sos2", "incremental", "auto"): raise ValueError( - f"method must be 'sos2', 'incremental', 'auto', or 'lp', got '{method}'" + f"method must be 'sos2', 'incremental', or 'auto', got '{method}'" ) # Coerce breakpoints @@ -1014,19 +884,12 @@ def _add_piecewise_2var( y_expr = _to_linexpr(y) active_expr = _to_linexpr(active) if active is not None else None - if active_expr is not None and method == "lp": - raise ValueError( - "The 'active' parameter is not supported with method='lp'. " - "Use method='incremental' or method='sos2'." - ) - if disjunctive: return _add_disjunctive( model, name, x_expr, y_expr, - sign, x_points, y_points, bp_mask, @@ -1039,7 +902,6 @@ def _add_piecewise_2var( name, x_expr, y_expr, - sign, x_points, y_points, bp_mask, @@ -1279,7 +1141,6 @@ def _add_continuous( name: str, x_expr: LinearExpression, y_expr: LinearExpression, - sign: str, x_points: DataArray, y_points: DataArray, mask: DataArray | None, @@ -1287,49 +1148,13 @@ def _add_continuous( skip_nan_check: bool, active: LinearExpression | None = None, ) -> Constraint: - """Handle continuous (non-disjunctive) piecewise constraints.""" - convexity: Literal["convex", "concave", "linear", "mixed"] | None = None - + """Handle continuous (non-disjunctive) piecewise equality constraints.""" # Determine actual method if method == "auto": - if sign == "==": - if _check_strict_monotonicity(x_points) and _has_trailing_nan_only( - x_points - ): - method = "incremental" - else: - method = "sos2" + if _check_strict_monotonicity(x_points) and _has_trailing_nan_only(x_points): + method = "incremental" else: - if not _check_strict_increasing(x_points): - raise ValueError( - "Automatic method selection for piecewise inequalities requires " - "strictly increasing x_points. Pass breakpoints in increasing " - "x-order or use method='sos2'." - ) - convexity = _detect_convexity(x_points, y_points) - if convexity == "linear": - method = "lp" - elif (sign == "<=" and convexity == "concave") or ( - sign == ">=" and convexity == "convex" - ): - method = "lp" - else: - method = "sos2" - elif method == "lp": - if sign == "==": - raise ValueError("Pure LP method is not supported for equality constraints") - convexity = _detect_convexity(x_points, y_points) - if convexity != "linear": - if sign == "<=" and convexity != "concave": - raise ValueError( - f"Pure LP method for '<=' requires concave or linear function, " - f"got {convexity}" - ) - if sign == ">=" and convexity != "convex": - raise ValueError( - f"Pure LP method for '>=' requires convex or linear function, " - f"got {convexity}" - ) + method = "sos2" elif method == "incremental": if not _check_strict_monotonicity(x_points): raise ValueError("Incremental method requires strictly monotonic x_points") @@ -1347,50 +1172,15 @@ def _add_continuous( "NaN values must only appear at the end of the breakpoint sequence." ) - # LP formulation - if method == "lp": - if active is not None: - raise ValueError( - "The 'active' parameter is not supported with method='lp'. " - "Use method='incremental' or method='sos2'." - ) - return _add_pwl_lp(model, name, x_expr, y_expr, sign, x_points, y_points) - - # SOS2 or incremental formulation - if sign == "==": - # Direct linking: y = f(x) - if method == "sos2": - return _add_pwl_sos2_core( - model, name, x_expr, y_expr, x_points, y_points, mask, active - ) - else: # incremental - return _add_pwl_incremental_core( - model, name, x_expr, y_expr, x_points, y_points, mask, active - ) - else: - # Inequality: create aux variable z, enforce z = f(x), then y <= z or y >= z - aux_name = f"{name}{PWL_AUX_SUFFIX}" - aux_coords = _extra_coords(x_points, BREAKPOINT_DIM) - z = model.add_variables(coords=aux_coords, name=aux_name) - z_expr = _to_linexpr(z) - - if method == "sos2": - result = _add_pwl_sos2_core( - model, name, x_expr, z_expr, x_points, y_points, mask, active - ) - else: # incremental - result = _add_pwl_incremental_core( - model, name, x_expr, z_expr, x_points, y_points, mask, active - ) - - # Add inequality - ineq_name = f"{name}_ineq" - if sign == "<=": - model.add_constraints(y_expr <= z_expr, name=ineq_name) - else: - model.add_constraints(y_expr >= z_expr, name=ineq_name) - - return result + # Direct linking: y = f(x) + if method == "sos2": + return _add_pwl_sos2_core( + model, name, x_expr, y_expr, x_points, y_points, mask, active + ) + else: # incremental + return _add_pwl_incremental_core( + model, name, x_expr, y_expr, x_points, y_points, mask, active + ) def _add_disjunctive( @@ -1398,16 +1188,13 @@ def _add_disjunctive( name: str, x_expr: LinearExpression, y_expr: LinearExpression, - sign: str, x_points: DataArray, y_points: DataArray, mask: DataArray | None, method: str, active: LinearExpression | None = None, ) -> Constraint: - """Handle disjunctive piecewise constraints.""" - if method == "lp": - raise ValueError("Pure LP method is not supported for disjunctive constraints") + """Handle disjunctive piecewise equality constraints.""" if method == "incremental": raise ValueError( "Incremental method is not supported for disjunctive constraints" @@ -1420,25 +1207,6 @@ def _add_disjunctive( "NaN values must only appear at the end of the breakpoint sequence." ) - if sign == "==": - return _add_dpwl_sos2_core( - model, name, x_expr, y_expr, x_points, y_points, mask, active - ) - else: - # Create aux variable z, disjunctive SOS2 for z = f(x), then y <= z or y >= z - aux_name = f"{name}{PWL_AUX_SUFFIX}" - aux_coords = _extra_coords(x_points, BREAKPOINT_DIM, SEGMENT_DIM) - z = model.add_variables(coords=aux_coords, name=aux_name) - z_expr = _to_linexpr(z) - - result = _add_dpwl_sos2_core( - model, name, x_expr, z_expr, x_points, y_points, mask, active - ) - - ineq_name = f"{name}_ineq" - if sign == "<=": - model.add_constraints(y_expr <= z_expr, name=ineq_name) - else: - model.add_constraints(y_expr >= z_expr, name=ineq_name) - - return result + return _add_dpwl_sos2_core( + model, name, x_expr, y_expr, x_points, y_points, mask, active + ) diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index b60db7a3..0f9a2b48 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -13,6 +13,7 @@ Model, available_solvers, breakpoints, + piecewise_envelope, segments, slopes_to_points, ) @@ -20,7 +21,6 @@ BREAKPOINT_DIM, LP_SEG_DIM, PWL_ACTIVE_BOUND_SUFFIX, - PWL_AUX_SUFFIX, PWL_BINARY_SUFFIX, PWL_CONVEX_SUFFIX, PWL_DELTA_SUFFIX, @@ -29,8 +29,6 @@ PWL_INC_LINK_SUFFIX, PWL_INC_ORDER_SUFFIX, PWL_LAMBDA_SUFFIX, - PWL_LP_DOMAIN_SUFFIX, - PWL_LP_SUFFIX, PWL_SELECT_SUFFIX, PWL_X_LINK_SUFFIX, PWL_Y_LINK_SUFFIX, @@ -359,148 +357,62 @@ def test_with_slopes(self) -> None: # =========================================================================== -# Continuous piecewise – inequality +# Piecewise Envelope # =========================================================================== -class TestContinuousInequality: - def test_concave_le_uses_lp(self) -> None: - """Y <= concave f(x) -> LP tangent lines""" +class TestPiecewiseEnvelope: + def test_basic_variable(self) -> None: + """Envelope from a Variable produces a LinearExpression with seg dim.""" m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - # Concave: slopes 0.8, 0.4 (decreasing) - # y <= pw -> sign="<=" - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 40, 60], - sign="<=", - ) - assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints - assert f"pwl0{PWL_LAMBDA_SUFFIX}" not in m.variables - assert f"pwl0{PWL_AUX_SUFFIX}" not in m.variables - - def test_convex_le_uses_sos2_aux(self) -> None: - """Y <= convex f(x) -> SOS2 + aux""" - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - # Convex: slopes 0.2, 1.0 (increasing) - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 10, 60], - sign="<=", - ) - assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables - assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables - - def test_convex_ge_uses_lp(self) -> None: - """Y >= convex f(x) -> LP tangent lines""" - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - # Convex: slopes 0.2, 1.0 (increasing) - # y >= pw -> sign=">=" - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 10, 60], - sign=">=", - ) - assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints - assert f"pwl0{PWL_LAMBDA_SUFFIX}" not in m.variables - assert f"pwl0{PWL_AUX_SUFFIX}" not in m.variables + x = m.add_variables(name="x", lower=0, upper=100) + env = piecewise_envelope(x, [0, 50, 100], [0, 40, 60]) + assert LP_SEG_DIM in env.dims - def test_concave_ge_uses_sos2_aux(self) -> None: - """Y >= concave f(x) -> SOS2 + aux""" + def test_basic_linexpr(self) -> None: + """Envelope from a LinearExpression works too.""" m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - # Concave: slopes 0.8, 0.4 (decreasing) - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 40, 60], - sign=">=", - ) - assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables - assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables + x = m.add_variables(name="x", lower=0, upper=100) + env = piecewise_envelope(1 * x, [0, 50, 100], [0, 40, 60]) + assert LP_SEG_DIM in env.dims - def test_mixed_uses_sos2(self) -> None: + def test_segment_count(self) -> None: + """Number of segments = number of breakpoints - 1.""" m = Model() x = m.add_variables(name="x") - y = m.add_variables(name="y") - # Mixed: slopes 0.5, 0.3, 0.9 (down then up) - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 30, 60, 100], - y_points=[0, 15, 24, 60], - sign="<=", - ) - assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables - assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables + env = piecewise_envelope(x, [0, 50, 100], [0, 40, 60]) + assert env.sizes[LP_SEG_DIM] == 2 - def test_method_lp_wrong_convexity_raises(self) -> None: - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - # Convex function + y <= pw + method="lp" should fail - with pytest.raises(ValueError, match="convex"): - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 10, 60], - sign="<=", - method="lp", - ) + def test_invalid_x_type_raises(self) -> None: + with pytest.raises(TypeError, match="must be a Variable or LinearExpression"): + piecewise_envelope(42, [0, 50, 100], [0, 40, 60]) # type: ignore - def test_method_lp_decreasing_breakpoints_raises(self) -> None: + def test_concave_le_constraint(self) -> None: + """Using envelope with <= constraint creates regular constraints.""" m = Model() - x = m.add_variables(name="x") + x = m.add_variables(name="x", lower=0, upper=100) y = m.add_variables(name="y") - with pytest.raises(ValueError, match="strictly increasing x_points"): - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[100, 50, 0], - y_points=[60, 10, 0], - sign=">=", - method="lp", - ) + env = piecewise_envelope(x, [0, 50, 100], [0, 40, 60]) + m.add_constraints(y <= env, name="pwl") + assert "pwl" in m.constraints - def test_auto_inequality_decreasing_breakpoints_raises(self) -> None: + def test_convex_ge_constraint(self) -> None: + """Using envelope with >= constraint creates regular constraints.""" m = Model() - x = m.add_variables(name="x") + x = m.add_variables(name="x", lower=0, upper=100) y = m.add_variables(name="y") - with pytest.raises(ValueError, match="strictly increasing x_points"): - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[100, 50, 0], - y_points=[60, 10, 0], - sign=">=", - ) + env = piecewise_envelope(x, [0, 50, 100], [0, 10, 60]) + m.add_constraints(y >= env, name="pwl") + assert "pwl" in m.constraints - def test_method_lp_equality_raises(self) -> None: + def test_dataarray_breakpoints(self) -> None: + """Envelope accepts DataArray breakpoints.""" m = Model() x = m.add_variables(name="x") - y = m.add_variables(name="y") - with pytest.raises(ValueError, match="equality"): - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 40, 60], - method="lp", - ) + x_pts = xr.DataArray([0, 50, 100], dims=[BREAKPOINT_DIM]) + y_pts = xr.DataArray([0, 40, 60], dims=[BREAKPOINT_DIM]) + env = piecewise_envelope(x, x_pts, y_pts) + assert LP_SEG_DIM in env.dims # =========================================================================== @@ -651,35 +563,6 @@ def test_equality_creates_binary(self) -> None: lam = m.variables[f"pwl0{PWL_LAMBDA_SUFFIX}"] assert lam.attrs.get("sos_type") == 2 - def test_inequality_creates_aux(self) -> None: - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - m.add_piecewise_constraints( - x=x, - y=y, - x_points=segments([[0, 10], [50, 100]]), - y_points=segments([[0, 5], [20, 80]]), - sign="<=", - ) - assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables - assert f"pwl0{PWL_BINARY_SUFFIX}" in m.variables - assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables - - def test_method_lp_raises(self) -> None: - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - with pytest.raises(ValueError, match="disjunctive"): - m.add_piecewise_constraints( - x=x, - y=y, - x_points=segments([[0, 10], [50, 100]]), - y_points=segments([[0, 5], [20, 80]]), - sign="<=", - method="lp", - ) - def test_method_incremental_raises(self) -> None: m = Model() x = m.add_variables(name="x") @@ -881,69 +764,6 @@ def test_sos2_interior_nan_raises(self) -> None: ) -# =========================================================================== -# Convexity detection edge cases -# =========================================================================== - - -class TestConvexityDetection: - def test_linear_uses_lp_both_directions(self) -> None: - """Linear function uses LP for both <= and >= inequalities.""" - m = Model() - x = m.add_variables(lower=0, upper=100, name="x") - y1 = m.add_variables(name="y1") - y2 = m.add_variables(name="y2") - # y1 >= f(x) -> LP - m.add_piecewise_constraints( - x=x, - y=y1, - x_points=[0, 50, 100], - y_points=[0, 25, 50], - sign=">=", - ) - assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints - # y2 <= f(x) -> also LP (linear is both convex and concave) - m.add_piecewise_constraints( - x=x, - y=y2, - x_points=[0, 50, 100], - y_points=[0, 25, 50], - sign="<=", - ) - assert f"pwl1{PWL_LP_SUFFIX}" in m.constraints - - def test_single_segment_uses_lp(self) -> None: - """A single segment (2 breakpoints) is linear; uses LP.""" - m = Model() - x = m.add_variables(lower=0, upper=100, name="x") - y = m.add_variables(name="y") - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 100], - y_points=[0, 50], - sign=">=", - ) - assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints - - def test_mixed_convexity_uses_sos2(self) -> None: - """Mixed convexity should fall back to SOS2 for inequalities.""" - m = Model() - x = m.add_variables(lower=0, upper=100, name="x") - y = m.add_variables(name="y") - # Mixed: slope goes up then down -> neither convex nor concave - # y <= f(x) -> sign="<=" - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 30, 60, 100], - y_points=[0, 40, 30, 50], - sign="<=", - ) - assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables - assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables - - # =========================================================================== # LP file output # =========================================================================== @@ -968,24 +788,6 @@ def test_sos2_equality(self, tmp_path: Path) -> None: assert "sos" in content assert "s2" in content - def test_lp_formulation_no_sos2(self, tmp_path: Path) -> None: - m = Model() - x = m.add_variables(name="x", lower=0, upper=100) - y = m.add_variables(name="y") - # Concave: y <= pw uses LP - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0.0, 50.0, 100.0], - y_points=[0.0, 40.0, 60.0], - sign="<=", - ) - m.add_objective(y) - fn = tmp_path / "pwl_lp.lp" - m.to_file(fn, io_api="lp") - content = fn.read_text().lower() - assert "s2" not in content - def test_disjunctive_sos2_and_binary(self, tmp_path: Path) -> None: m = Model() x = m.add_variables(name="x", lower=0, upper=100) @@ -1005,7 +807,7 @@ def test_disjunctive_sos2_and_binary(self, tmp_path: Path) -> None: # =========================================================================== -# Solver integration – SOS2 capable +# Solver integration -- SOS2 capable # =========================================================================== @@ -1068,12 +870,12 @@ def test_disjunctive_solve(self, solver_name: str) -> None: # =========================================================================== -# Solver integration – LP formulation (any solver) +# Solver integration -- Envelope (any solver) # =========================================================================== @pytest.mark.skipif(len(_any_solvers) == 0, reason="No solver available") -class TestSolverLP: +class TestSolverEnvelope: @pytest.fixture(params=_any_solvers) def solver_name(self, request: pytest.FixtureRequest) -> str: return request.param @@ -1084,14 +886,10 @@ def test_concave_le(self, solver_name: str) -> None: x = m.add_variables(lower=0, upper=100, name="x") y = m.add_variables(name="y") # Concave: [0,0],[50,40],[100,60] - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 40, 60], - sign="<=", - ) + env = piecewise_envelope(x, [0, 50, 100], [0, 40, 60]) + m.add_constraints(y <= env, name="pwl") m.add_constraints(x <= 75, name="x_max") + m.add_constraints(x >= 0, name="x_lo") m.add_objective(y, sense="max") status, _ = m.solve(solver_name=solver_name) assert status == "ok" @@ -1105,13 +903,8 @@ def test_convex_ge(self, solver_name: str) -> None: x = m.add_variables(lower=0, upper=100, name="x") y = m.add_variables(name="y") # Convex: [0,0],[50,10],[100,60] - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 10, 60], - sign=">=", - ) + env = piecewise_envelope(x, [0, 50, 100], [0, 10, 60]) + m.add_constraints(y >= env, name="pwl") m.add_constraints(x >= 25, name="x_min") m.add_objective(y) status, _ = m.solve(solver_name=solver_name) @@ -1126,14 +919,10 @@ def test_slopes_equivalence(self, solver_name: str) -> None: m1 = Model() x1 = m1.add_variables(lower=0, upper=100, name="x") y1 = m1.add_variables(name="y") - m1.add_piecewise_constraints( - x=x1, - y=y1, - x_points=[0, 50, 100], - y_points=[0, 40, 60], - sign="<=", - ) + env1 = piecewise_envelope(x1, [0, 50, 100], [0, 40, 60]) + m1.add_constraints(y1 <= env1, name="pwl") m1.add_constraints(x1 <= 75, name="x_max") + m1.add_constraints(x1 >= 0, name="x_lo") m1.add_objective(y1, sense="max") s1, _ = m1.solve(solver_name=solver_name) @@ -1141,14 +930,14 @@ def test_slopes_equivalence(self, solver_name: str) -> None: m2 = Model() x2 = m2.add_variables(lower=0, upper=100, name="x") y2 = m2.add_variables(name="y") - m2.add_piecewise_constraints( - x=x2, - y=y2, - x_points=[0, 50, 100], - y_points=breakpoints(slopes=[0.8, 0.4], x_points=[0, 50, 100], y0=0), - sign="<=", + env2 = piecewise_envelope( + x2, + [0, 50, 100], + breakpoints(slopes=[0.8, 0.4], x_points=[0, 50, 100], y0=0), ) + m2.add_constraints(y2 <= env2, name="pwl") m2.add_constraints(x2 <= 75, name="x_max") + m2.add_constraints(x2 >= 0, name="x_lo") m2.add_objective(y2, sense="max") s2, _ = m2.solve(solver_name=solver_name) @@ -1159,48 +948,6 @@ def test_slopes_equivalence(self, solver_name: str) -> None: ) -class TestLPDomainConstraints: - """Tests for LP domain bound constraints.""" - - def test_lp_domain_constraints_created(self) -> None: - """LP method creates domain bound constraints.""" - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - # Concave: slopes decreasing -> y <= pw uses LP - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 40, 60], - sign="<=", - ) - assert f"pwl0{PWL_LP_DOMAIN_SUFFIX}_lo" in m.constraints - assert f"pwl0{PWL_LP_DOMAIN_SUFFIX}_hi" in m.constraints - - def test_lp_domain_constraints_multidim(self) -> None: - """Domain constraints have entity dimension for per-entity breakpoints.""" - m = Model() - x = m.add_variables(coords=[pd.Index(["a", "b"], name="entity")], name="x") - y = m.add_variables(coords=[pd.Index(["a", "b"], name="entity")], name="y") - x_pts = breakpoints({"a": [0, 50, 100], "b": [10, 60, 110]}, dim="entity") - y_pts = breakpoints({"a": [0, 40, 60], "b": [5, 35, 55]}, dim="entity") - m.add_piecewise_constraints( - x=x, - y=y, - x_points=x_pts, - y_points=y_pts, - sign="<=", - ) - lo_name = f"pwl0{PWL_LP_DOMAIN_SUFFIX}_lo" - hi_name = f"pwl0{PWL_LP_DOMAIN_SUFFIX}_hi" - assert lo_name in m.constraints - assert hi_name in m.constraints - # Domain constraints should have the entity dimension - assert "entity" in m.constraints[lo_name].labels.dims - assert "entity" in m.constraints[hi_name].labels.dims - - # =========================================================================== # Active parameter (commitment binary) # =========================================================================== @@ -1239,57 +986,6 @@ def test_active_none_is_default(self) -> None: ) assert f"pwl0{PWL_ACTIVE_BOUND_SUFFIX}" not in m.constraints - def test_active_with_lp_method_raises(self) -> None: - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - u = m.add_variables(binary=True, name="u") - with pytest.raises(ValueError, match="not supported with method='lp'"): - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 40, 60], - sign="<=", - active=u, - method="lp", - ) - - def test_active_with_auto_lp_raises(self) -> None: - """Auto selects LP for concave <=, but active is incompatible.""" - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - u = m.add_variables(binary=True, name="u") - with pytest.raises(ValueError, match="not supported with method='lp'"): - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 40, 60], - sign="<=", - active=u, - ) - - def test_incremental_inequality_with_active(self) -> None: - """Inequality + active creates aux variable and active bound.""" - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - u = m.add_variables(binary=True, name="u") - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 10, 50], - sign="<=", - active=u, - method="incremental", - ) - assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables - assert f"pwl0{PWL_ACTIVE_BOUND_SUFFIX}" in m.constraints - assert "pwl0_ineq" in m.constraints - def test_active_with_linear_expression(self) -> None: """Active can be a LinearExpression, not just a Variable.""" m = Model() @@ -1308,7 +1004,7 @@ def test_active_with_linear_expression(self) -> None: # =========================================================================== -# Solver integration – active parameter +# Solver integration -- active parameter # =========================================================================== @@ -1387,27 +1083,6 @@ def test_incremental_nonzero_base_active_off(self, solver_name: str) -> None: np.testing.assert_allclose(float(x.solution.values), 0, atol=1e-4) np.testing.assert_allclose(float(y.solution.values), 0, atol=1e-4) - def test_incremental_inequality_active_off(self, solver_name: str) -> None: - """Inequality with active=0: aux variable is 0, so y <= 0.""" - m = Model() - x = m.add_variables(lower=0, upper=100, name="x") - y = m.add_variables(lower=0, name="y") - u = m.add_variables(binary=True, name="u") - m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 10, 50], - sign="<=", - active=u, - method="incremental", - ) - m.add_constraints(u <= 0, name="force_off") - m.add_objective(y, sense="max") - status, _ = m.solve(solver_name=solver_name) - assert status == "ok" - np.testing.assert_allclose(float(y.solution.values), 0, atol=1e-4) - def test_unit_commitment_pattern(self, solver_name: str) -> None: """Solver decides to commit: verifies correct fuel at operating point.""" m = Model() @@ -1566,18 +1241,6 @@ def test_auto_selects_method(self) -> None: # Auto should select incremental for monotonic breakpoints assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables - def test_lp_method_raises(self) -> None: - m = Model() - power = m.add_variables(lower=0, upper=100, name="power") - fuel = m.add_variables(name="fuel") - bp = self._make_chp_breakpoints() - with pytest.raises(ValueError, match="not supported for N-variable"): - m.add_piecewise_constraints( - exprs={"power": power, "fuel": fuel}, - breakpoints=bp, - method="lp", - ) - def test_missing_breakpoints_raises(self) -> None: m = Model() power = m.add_variables(name="power") From dd51e82252db15e871ac0ecd05cc9ac8df374c1d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:13:29 +0200 Subject: [PATCH 08/30] rename piecewise_envelope to piecewise_tangents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More accurate name — the function computes tangent lines per segment, not necessarily a convex/concave envelope. Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/piecewise-linear-constraints.rst | 16 +- examples/piecewise-linear-constraints.ipynb | 3701 ++++++++++++++++++- linopy/__init__.py | 4 +- linopy/linearization.py | 4 +- linopy/piecewise.py | 2 +- test/test_piecewise_constraints.py | 24 +- 6 files changed, 3713 insertions(+), 38 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 9b7bafed..5f435a7a 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -9,7 +9,7 @@ production functions within a linear programming framework. Use :py:meth:`~linopy.model.Model.add_piecewise_constraints` to add piecewise equality constraints to a model. For inequality constraints (upper/lower -envelopes), use :func:`~linopy.linearization.piecewise_envelope`. +envelopes), use :func:`~linopy.linearization.piecewise_tangents`. .. contents:: :local: @@ -46,12 +46,12 @@ lie on the interpolated breakpoint curve. **Envelope (inequality):** For inequality constraints such as :math:`y \le f(x)` or :math:`y \ge f(x)`, use -:func:`~linopy.linearization.piecewise_envelope` to obtain tangent-line +:func:`~linopy.linearization.piecewise_tangents` to obtain tangent-line expressions and add them as regular constraints: .. code-block:: python - envelope = linopy.piecewise_envelope(power, x_pts, y_pts) + envelope = linopy.piecewise_tangents(power, x_pts, y_pts) m.add_constraints(fuel <= envelope) # upper bound (concave f) m.add_constraints(fuel >= envelope) # lower bound (convex f) @@ -133,7 +133,7 @@ same N-variable code path. Piecewise Envelope (inequality) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -For inequality constraints, use :func:`~linopy.linearization.piecewise_envelope` +For inequality constraints, use :func:`~linopy.linearization.piecewise_tangents` instead of ``add_piecewise_constraints``. The envelope function computes tangent-line expressions for each segment --- no auxiliary variables are created: @@ -145,7 +145,7 @@ Use the result in a regular constraint: .. code-block:: python - envelope = linopy.piecewise_envelope(power, x_pts, y_pts) + envelope = linopy.piecewise_tangents(power, x_pts, y_pts) m.add_constraints(fuel <= envelope) # upper bound (concave f) m.add_constraints(fuel >= envelope) # lower bound (convex f) @@ -208,7 +208,7 @@ Pass ``method="auto"`` (the default) and linopy picks the best formulation: - **Equality + monotonic breakpoints** -> incremental - Otherwise -> SOS2 - Disjunctive (segments) -> always SOS2 with binary selection -- **Inequality** -> use ``piecewise_envelope`` + regular constraints +- **Inequality** -> use ``piecewise_tangents`` + regular constraints .. list-table:: :header-rows: 1 @@ -261,11 +261,11 @@ Inequality via envelope .. code-block:: python # fuel <= f(power): y bounded above (concave function) - envelope = linopy.piecewise_envelope(power, x_pts, y_pts) + envelope = linopy.piecewise_tangents(power, x_pts, y_pts) m.add_constraints(fuel <= envelope) # fuel >= f(power): y bounded below (convex function) - envelope = linopy.piecewise_envelope(power, x_pts, y_pts) + envelope = linopy.piecewise_tangents(power, x_pts, y_pts) m.add_constraints(fuel >= envelope) N-variable linking diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 29f721b9..093bde5f 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -3,7 +3,1018 @@ { "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 | Envelope |\n| 5 | Gas unit with commitment | On/off + min load | Incremental + `active` |\n| 6 | CHP plant (N-variable) | Joint power/fuel/heat | N-variable SOS2 |\n\n**API:**\n- **2-variable:** `m.add_piecewise_constraints(x=power, y=fuel, x_points=xp, y_points=yp)`\n- **N-variable:** `m.add_piecewise_constraints(exprs={...}, breakpoints=bp)`\n- **Envelope (inequality):** `linopy.piecewise_envelope(x, x_pts, y_pts)` + regular constraints" + "source": [ + "#", + " ", + "P", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + " ", + "L", + "i", + "n", + "e", + "a", + "r", + " ", + "C", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + " ", + "T", + "u", + "t", + "o", + "r", + "i", + "a", + "l", + "\n", + "\n", + "T", + "h", + "i", + "s", + " ", + "n", + "o", + "t", + "e", + "b", + "o", + "o", + "k", + " ", + "d", + "e", + "m", + "o", + "n", + "s", + "t", + "r", + "a", + "t", + "e", + "s", + " ", + "l", + "i", + "n", + "o", + "p", + "y", + "'", + "s", + " ", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + " ", + "l", + "i", + "n", + "e", + "a", + "r", + " ", + "(", + "P", + "W", + "L", + ")", + " ", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + " ", + "f", + "o", + "r", + "m", + "u", + "l", + "a", + "t", + "i", + "o", + "n", + "s", + ".", + "\n", + "E", + "a", + "c", + "h", + " ", + "e", + "x", + "a", + "m", + "p", + "l", + "e", + " ", + "b", + "u", + "i", + "l", + "d", + "s", + " ", + "a", + " ", + "s", + "e", + "p", + "a", + "r", + "a", + "t", + "e", + " ", + "d", + "i", + "s", + "p", + "a", + "t", + "c", + "h", + " ", + "m", + "o", + "d", + "e", + "l", + " ", + "w", + "h", + "e", + "r", + "e", + " ", + "a", + " ", + "s", + "i", + "n", + "g", + "l", + "e", + " ", + "p", + "o", + "w", + "e", + "r", + " ", + "p", + "l", + "a", + "n", + "t", + " ", + "m", + "u", + "s", + "t", + " ", + "m", + "e", + "e", + "t", + "\n", + "a", + " ", + "t", + "i", + "m", + "e", + "-", + "v", + "a", + "r", + "y", + "i", + "n", + "g", + " ", + "d", + "e", + "m", + "a", + "n", + "d", + ".", + "\n", + "\n", + "|", + " ", + "E", + "x", + "a", + "m", + "p", + "l", + "e", + " ", + "|", + " ", + "P", + "l", + "a", + "n", + "t", + " ", + "|", + " ", + "L", + "i", + "m", + "i", + "t", + "a", + "t", + "i", + "o", + "n", + " ", + "|", + " ", + "F", + "o", + "r", + "m", + "u", + "l", + "a", + "t", + "i", + "o", + "n", + " ", + "|", + "\n", + "|", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "|", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "|", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "|", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "|", + "\n", + "|", + " ", + "1", + " ", + "|", + " ", + "G", + "a", + "s", + " ", + "t", + "u", + "r", + "b", + "i", + "n", + "e", + " ", + "(", + "0", + "-", + "1", + "0", + "0", + " ", + "M", + "W", + ")", + " ", + "|", + " ", + "C", + "o", + "n", + "v", + "e", + "x", + " ", + "h", + "e", + "a", + "t", + " ", + "r", + "a", + "t", + "e", + " ", + "|", + " ", + "S", + "O", + "S", + "2", + " ", + "|", + "\n", + "|", + " ", + "2", + " ", + "|", + " ", + "C", + "o", + "a", + "l", + " ", + "p", + "l", + "a", + "n", + "t", + " ", + "(", + "0", + "-", + "1", + "5", + "0", + " ", + "M", + "W", + ")", + " ", + "|", + " ", + "M", + "o", + "n", + "o", + "t", + "o", + "n", + "i", + "c", + " ", + "h", + "e", + "a", + "t", + " ", + "r", + "a", + "t", + "e", + " ", + "|", + " ", + "I", + "n", + "c", + "r", + "e", + "m", + "e", + "n", + "t", + "a", + "l", + " ", + "|", + "\n", + "|", + " ", + "3", + " ", + "|", + " ", + "D", + "i", + "e", + "s", + "e", + "l", + " ", + "g", + "e", + "n", + "e", + "r", + "a", + "t", + "o", + "r", + " ", + "(", + "o", + "f", + "f", + " ", + "o", + "r", + " ", + "5", + "0", + "-", + "8", + "0", + " ", + "M", + "W", + ")", + " ", + "|", + " ", + "F", + "o", + "r", + "b", + "i", + "d", + "d", + "e", + "n", + " ", + "z", + "o", + "n", + "e", + " ", + "|", + " ", + "D", + "i", + "s", + "j", + "u", + "n", + "c", + "t", + "i", + "v", + "e", + " ", + "|", + "\n", + "|", + " ", + "4", + " ", + "|", + " ", + "C", + "o", + "n", + "c", + "a", + "v", + "e", + " ", + "e", + "f", + "f", + "i", + "c", + "i", + "e", + "n", + "c", + "y", + " ", + "c", + "u", + "r", + "v", + "e", + " ", + "|", + " ", + "I", + "n", + "e", + "q", + "u", + "a", + "l", + "i", + "t", + "y", + " ", + "b", + "o", + "u", + "n", + "d", + " ", + "|", + " ", + "E", + "n", + "v", + "e", + "l", + "o", + "p", + "e", + " ", + "|", + "\n", + "|", + " ", + "5", + " ", + "|", + " ", + "G", + "a", + "s", + " ", + "u", + "n", + "i", + "t", + " ", + "w", + "i", + "t", + "h", + " ", + "c", + "o", + "m", + "m", + "i", + "t", + "m", + "e", + "n", + "t", + " ", + "|", + " ", + "O", + "n", + "/", + "o", + "f", + "f", + " ", + "+", + " ", + "m", + "i", + "n", + " ", + "l", + "o", + "a", + "d", + " ", + "|", + " ", + "I", + "n", + "c", + "r", + "e", + "m", + "e", + "n", + "t", + "a", + "l", + " ", + "+", + " ", + "`", + "a", + "c", + "t", + "i", + "v", + "e", + "`", + " ", + "|", + "\n", + "|", + " ", + "6", + " ", + "|", + " ", + "C", + "H", + "P", + " ", + "p", + "l", + "a", + "n", + "t", + " ", + "(", + "N", + "-", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + ")", + " ", + "|", + " ", + "J", + "o", + "i", + "n", + "t", + " ", + "p", + "o", + "w", + "e", + "r", + "/", + "f", + "u", + "e", + "l", + "/", + "h", + "e", + "a", + "t", + " ", + "|", + " ", + "N", + "-", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + " ", + "S", + "O", + "S", + "2", + " ", + "|", + "\n", + "\n", + "*", + "*", + "A", + "P", + "I", + ":", + "*", + "*", + "\n", + "-", + " ", + "*", + "*", + "2", + "-", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + ":", + "*", + "*", + " ", + "`", + "m", + ".", + "a", + "d", + "d", + "_", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + "x", + "=", + "p", + "o", + "w", + "e", + "r", + ",", + " ", + "y", + "=", + "f", + "u", + "e", + "l", + ",", + " ", + "x", + "_", + "p", + "o", + "i", + "n", + "t", + "s", + "=", + "x", + "p", + ",", + " ", + "y", + "_", + "p", + "o", + "i", + "n", + "t", + "s", + "=", + "y", + "p", + ")", + "`", + "\n", + "-", + " ", + "*", + "*", + "N", + "-", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + ":", + "*", + "*", + " ", + "`", + "m", + ".", + "a", + "d", + "d", + "_", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + "e", + "x", + "p", + "r", + "s", + "=", + "{", + ".", + ".", + ".", + "}", + ",", + " ", + "b", + "r", + "e", + "a", + "k", + "p", + "o", + "i", + "n", + "t", + "s", + "=", + "b", + "p", + ")", + "`", + "\n", + "-", + " ", + "*", + "*", + "E", + "n", + "v", + "e", + "l", + "o", + "p", + "e", + " ", + "(", + "i", + "n", + "e", + "q", + "u", + "a", + "l", + "i", + "t", + "y", + ")", + ":", + "*", + "*", + " ", + "`", + "l", + "i", + "n", + "o", + "p", + "y", + ".", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "_", + "e", + "n", + "v", + "e", + "l", + "o", + "p", + "e", + "(", + "x", + ",", + " ", + "x", + "_", + "p", + "t", + "s", + ",", + " ", + "y", + "_", + "p", + "t", + "s", + ")", + "`", + " ", + "+", + " ", + "r", + "e", + "g", + "u", + "l", + "a", + "r", + " ", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s" + ] }, { "cell_type": "code", @@ -111,7 +1122,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", @@ -248,11 +1259,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." ] }, { @@ -384,10 +1395,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", @@ -504,7 +1515,397 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## 4. Envelope formulation — Concave efficiency bound\n\nWhen the piecewise function is **concave** and we want to bound y **above**\n(i.e. `y <= f(x)`), we can use `piecewise_envelope` to get tangent-line\nexpressions and add them as regular constraints — no SOS2 or binary\nvariables needed. This is the fastest to solve.\n\nHere we bound fuel consumption *below* a concave efficiency envelope." + "source": [ + "#", + "#", + " ", + "4", + ".", + " ", + "E", + "n", + "v", + "e", + "l", + "o", + "p", + "e", + " ", + "f", + "o", + "r", + "m", + "u", + "l", + "a", + "t", + "i", + "o", + "n", + " ", + "\u2014", + " ", + "C", + "o", + "n", + "c", + "a", + "v", + "e", + " ", + "e", + "f", + "f", + "i", + "c", + "i", + "e", + "n", + "c", + "y", + " ", + "b", + "o", + "u", + "n", + "d", + "\n", + "\n", + "W", + "h", + "e", + "n", + " ", + "t", + "h", + "e", + " ", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + " ", + "f", + "u", + "n", + "c", + "t", + "i", + "o", + "n", + " ", + "i", + "s", + " ", + "*", + "*", + "c", + "o", + "n", + "c", + "a", + "v", + "e", + "*", + "*", + " ", + "a", + "n", + "d", + " ", + "w", + "e", + " ", + "w", + "a", + "n", + "t", + " ", + "t", + "o", + " ", + "b", + "o", + "u", + "n", + "d", + " ", + "y", + " ", + "*", + "*", + "a", + "b", + "o", + "v", + "e", + "*", + "*", + "\n", + "(", + "i", + ".", + "e", + ".", + " ", + "`", + "y", + " ", + "<", + "=", + " ", + "f", + "(", + "x", + ")", + "`", + ")", + ",", + " ", + "w", + "e", + " ", + "c", + "a", + "n", + " ", + "u", + "s", + "e", + " ", + "`", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "_", + "e", + "n", + "v", + "e", + "l", + "o", + "p", + "e", + "`", + " ", + "t", + "o", + " ", + "g", + "e", + "t", + " ", + "t", + "a", + "n", + "g", + "e", + "n", + "t", + "-", + "l", + "i", + "n", + "e", + "\n", + "e", + "x", + "p", + "r", + "e", + "s", + "s", + "i", + "o", + "n", + "s", + " ", + "a", + "n", + "d", + " ", + "a", + "d", + "d", + " ", + "t", + "h", + "e", + "m", + " ", + "a", + "s", + " ", + "r", + "e", + "g", + "u", + "l", + "a", + "r", + " ", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + " ", + "\u2014", + " ", + "n", + "o", + " ", + "S", + "O", + "S", + "2", + " ", + "o", + "r", + " ", + "b", + "i", + "n", + "a", + "r", + "y", + "\n", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + " ", + "n", + "e", + "e", + "d", + "e", + "d", + ".", + " ", + "T", + "h", + "i", + "s", + " ", + "i", + "s", + " ", + "t", + "h", + "e", + " ", + "f", + "a", + "s", + "t", + "e", + "s", + "t", + " ", + "t", + "o", + " ", + "s", + "o", + "l", + "v", + "e", + ".", + "\n", + "\n", + "H", + "e", + "r", + "e", + " ", + "w", + "e", + " ", + "b", + "o", + "u", + "n", + "d", + " ", + "f", + "u", + "e", + "l", + " ", + "c", + "o", + "n", + "s", + "u", + "m", + "p", + "t", + "i", + "o", + "n", + " ", + "*", + "b", + "e", + "l", + "o", + "w", + "*", + " ", + "a", + " ", + "c", + "o", + "n", + "c", + "a", + "v", + "e", + " ", + "e", + "f", + "f", + "i", + "c", + "i", + "e", + "n", + "c", + "y", + " ", + "e", + "n", + "v", + "e", + "l", + "o", + "p", + "e", + "." + ] }, { "cell_type": "code", @@ -523,7 +1924,685 @@ } }, "outputs": [], - "source": "x_pts4 = linopy.breakpoints([0, 40, 80, 120])\n# Concave curve: decreasing marginal fuel per MW\ny_pts4 = linopy.breakpoints([0, 50, 90, 120])\n\nm4 = linopy.Model()\n\npower = m4.add_variables(name=\"power\", lower=0, upper=120, coords=[time])\nfuel = m4.add_variables(name=\"fuel\", lower=0, coords=[time])\n\n# Use piecewise_envelope to get tangent-line expressions, then add as <= constraint\nenvelope = linopy.piecewise_envelope(power, x_pts4, y_pts4)\nm4.add_constraints(fuel <= envelope, name=\"pwl\")\n\ndemand4 = xr.DataArray([30, 80, 100], coords=[time])\nm4.add_constraints(power == demand4, name=\"demand\")\n# Maximize fuel (to push against the upper bound)\nm4.add_objective(-fuel.sum())" + "source": [ + "x", + "_", + "p", + "t", + "s", + "4", + " ", + "=", + " ", + "l", + "i", + "n", + "o", + "p", + "y", + ".", + "b", + "r", + "e", + "a", + "k", + "p", + "o", + "i", + "n", + "t", + "s", + "(", + "[", + "0", + ",", + " ", + "4", + "0", + ",", + " ", + "8", + "0", + ",", + " ", + "1", + "2", + "0", + "]", + ")", + "\n", + "#", + " ", + "C", + "o", + "n", + "c", + "a", + "v", + "e", + " ", + "c", + "u", + "r", + "v", + "e", + ":", + " ", + "d", + "e", + "c", + "r", + "e", + "a", + "s", + "i", + "n", + "g", + " ", + "m", + "a", + "r", + "g", + "i", + "n", + "a", + "l", + " ", + "f", + "u", + "e", + "l", + " ", + "p", + "e", + "r", + " ", + "M", + "W", + "\n", + "y", + "_", + "p", + "t", + "s", + "4", + " ", + "=", + " ", + "l", + "i", + "n", + "o", + "p", + "y", + ".", + "b", + "r", + "e", + "a", + "k", + "p", + "o", + "i", + "n", + "t", + "s", + "(", + "[", + "0", + ",", + " ", + "5", + "0", + ",", + " ", + "9", + "0", + ",", + " ", + "1", + "2", + "0", + "]", + ")", + "\n", + "\n", + "m", + "4", + " ", + "=", + " ", + "l", + "i", + "n", + "o", + "p", + "y", + ".", + "M", + "o", + "d", + "e", + "l", + "(", + ")", + "\n", + "\n", + "p", + "o", + "w", + "e", + "r", + " ", + "=", + " ", + "m", + "4", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "p", + "o", + "w", + "e", + "r", + "\"", + ",", + " ", + "l", + "o", + "w", + "e", + "r", + "=", + "0", + ",", + " ", + "u", + "p", + "p", + "e", + "r", + "=", + "1", + "2", + "0", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "f", + "u", + "e", + "l", + " ", + "=", + " ", + "m", + "4", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "f", + "u", + "e", + "l", + "\"", + ",", + " ", + "l", + "o", + "w", + "e", + "r", + "=", + "0", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "\n", + "#", + " ", + "U", + "s", + "e", + " ", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "_", + "e", + "n", + "v", + "e", + "l", + "o", + "p", + "e", + " ", + "t", + "o", + " ", + "g", + "e", + "t", + " ", + "t", + "a", + "n", + "g", + "e", + "n", + "t", + "-", + "l", + "i", + "n", + "e", + " ", + "e", + "x", + "p", + "r", + "e", + "s", + "s", + "i", + "o", + "n", + "s", + ",", + " ", + "t", + "h", + "e", + "n", + " ", + "a", + "d", + "d", + " ", + "a", + "s", + " ", + "<", + "=", + " ", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "\n", + "e", + "n", + "v", + "e", + "l", + "o", + "p", + "e", + " ", + "=", + " ", + "l", + "i", + "n", + "o", + "p", + "y", + ".", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "_", + "e", + "n", + "v", + "e", + "l", + "o", + "p", + "e", + "(", + "p", + "o", + "w", + "e", + "r", + ",", + " ", + "x", + "_", + "p", + "t", + "s", + "4", + ",", + " ", + "y", + "_", + "p", + "t", + "s", + "4", + ")", + "\n", + "m", + "4", + ".", + "a", + "d", + "d", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + "f", + "u", + "e", + "l", + " ", + "<", + "=", + " ", + "e", + "n", + "v", + "e", + "l", + "o", + "p", + "e", + ",", + " ", + "n", + "a", + "m", + "e", + "=", + "\"", + "p", + "w", + "l", + "\"", + ")", + "\n", + "\n", + "d", + "e", + "m", + "a", + "n", + "d", + "4", + " ", + "=", + " ", + "x", + "r", + ".", + "D", + "a", + "t", + "a", + "A", + "r", + "r", + "a", + "y", + "(", + "[", + "3", + "0", + ",", + " ", + "8", + "0", + ",", + " ", + "1", + "0", + "0", + "]", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "m", + "4", + ".", + "a", + "d", + "d", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + "p", + "o", + "w", + "e", + "r", + " ", + "=", + "=", + " ", + "d", + "e", + "m", + "a", + "n", + "d", + "4", + ",", + " ", + "n", + "a", + "m", + "e", + "=", + "\"", + "d", + "e", + "m", + "a", + "n", + "d", + "\"", + ")", + "\n", + "#", + " ", + "M", + "a", + "x", + "i", + "m", + "i", + "z", + "e", + " ", + "f", + "u", + "e", + "l", + " ", + "(", + "t", + "o", + " ", + "p", + "u", + "s", + "h", + " ", + "a", + "g", + "a", + "i", + "n", + "s", + "t", + " ", + "t", + "h", + "e", + " ", + "u", + "p", + "p", + "e", + "r", + " ", + "b", + "o", + "u", + "n", + "d", + ")", + "\n", + "m", + "4", + ".", + "a", + "d", + "d", + "_", + "o", + "b", + "j", + "e", + "c", + "t", + "i", + "v", + "e", + "(", + "-", + "f", + "u", + "e", + "l", + ".", + "s", + "u", + "m", + "(", + ")", + ")" + ] }, { "cell_type": "code", @@ -593,7 +2672,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", @@ -627,7 +2706,915 @@ { "cell_type": "markdown", "metadata": {}, - "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` keyword on `add_piecewise_constraints()` handles this by\ngating the internal 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", + ".", + " ", + "A", + "c", + "t", + "i", + "v", + "e", + " ", + "p", + "a", + "r", + "a", + "m", + "e", + "t", + "e", + "r", + " ", + "-", + "-", + " ", + "U", + "n", + "i", + "t", + " ", + "c", + "o", + "m", + "m", + "i", + "t", + "m", + "e", + "n", + "t", + " ", + "w", + "i", + "t", + "h", + " ", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + " ", + "e", + "f", + "f", + "i", + "c", + "i", + "e", + "n", + "c", + "y", + "\n", + "\n", + "I", + "n", + " ", + "u", + "n", + "i", + "t", + " ", + "c", + "o", + "m", + "m", + "i", + "t", + "m", + "e", + "n", + "t", + " ", + "p", + "r", + "o", + "b", + "l", + "e", + "m", + "s", + ",", + " ", + "a", + " ", + "b", + "i", + "n", + "a", + "r", + "y", + " ", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + " ", + "$", + "u", + "_", + "t", + "$", + " ", + "c", + "o", + "n", + "t", + "r", + "o", + "l", + "s", + " ", + "w", + "h", + "e", + "t", + "h", + "e", + "r", + " ", + "a", + "\n", + "u", + "n", + "i", + "t", + " ", + "i", + "s", + " ", + "*", + "*", + "o", + "n", + "*", + "*", + " ", + "o", + "r", + " ", + "*", + "*", + "o", + "f", + "f", + "*", + "*", + ".", + " ", + "W", + "h", + "e", + "n", + " ", + "o", + "f", + "f", + ",", + " ", + "b", + "o", + "t", + "h", + " ", + "p", + "o", + "w", + "e", + "r", + " ", + "o", + "u", + "t", + "p", + "u", + "t", + " ", + "a", + "n", + "d", + " ", + "f", + "u", + "e", + "l", + " ", + "c", + "o", + "n", + "s", + "u", + "m", + "p", + "t", + "i", + "o", + "n", + "\n", + "m", + "u", + "s", + "t", + " ", + "b", + "e", + " ", + "z", + "e", + "r", + "o", + ".", + " ", + "W", + "h", + "e", + "n", + " ", + "o", + "n", + ",", + " ", + "t", + "h", + "e", + " ", + "u", + "n", + "i", + "t", + " ", + "o", + "p", + "e", + "r", + "a", + "t", + "e", + "s", + " ", + "w", + "i", + "t", + "h", + "i", + "n", + " ", + "i", + "t", + "s", + " ", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "-", + "l", + "i", + "n", + "e", + "a", + "r", + "\n", + "e", + "f", + "f", + "i", + "c", + "i", + "e", + "n", + "c", + "y", + " ", + "c", + "u", + "r", + "v", + "e", + " ", + "b", + "e", + "t", + "w", + "e", + "e", + "n", + " ", + "$", + "P", + "_", + "{", + "m", + "i", + "n", + "}", + "$", + " ", + "a", + "n", + "d", + " ", + "$", + "P", + "_", + "{", + "m", + "a", + "x", + "}", + "$", + ".", + "\n", + "\n", + "T", + "h", + "e", + " ", + "`", + "a", + "c", + "t", + "i", + "v", + "e", + "`", + " ", + "k", + "e", + "y", + "w", + "o", + "r", + "d", + " ", + "o", + "n", + " ", + "`", + "a", + "d", + "d", + "_", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + ")", + "`", + " ", + "h", + "a", + "n", + "d", + "l", + "e", + "s", + " ", + "t", + "h", + "i", + "s", + " ", + "b", + "y", + "\n", + "g", + "a", + "t", + "i", + "n", + "g", + " ", + "t", + "h", + "e", + " ", + "i", + "n", + "t", + "e", + "r", + "n", + "a", + "l", + " ", + "P", + "W", + "L", + " ", + "f", + "o", + "r", + "m", + "u", + "l", + "a", + "t", + "i", + "o", + "n", + " ", + "w", + "i", + "t", + "h", + " ", + "t", + "h", + "e", + " ", + "c", + "o", + "m", + "m", + "i", + "t", + "m", + "e", + "n", + "t", + " ", + "b", + "i", + "n", + "a", + "r", + "y", + ":", + "\n", + "\n", + "-", + " ", + "*", + "*", + "I", + "n", + "c", + "r", + "e", + "m", + "e", + "n", + "t", + "a", + "l", + ":", + "*", + "*", + " ", + "d", + "e", + "l", + "t", + "a", + " ", + "b", + "o", + "u", + "n", + "d", + "s", + " ", + "t", + "i", + "g", + "h", + "t", + "e", + "n", + " ", + "f", + "r", + "o", + "m", + " ", + "$", + "\\", + "d", + "e", + "l", + "t", + "a", + "_", + "i", + " ", + "\\", + "l", + "e", + "q", + " ", + "1", + "$", + " ", + "t", + "o", + "\n", + " ", + " ", + "$", + "\\", + "d", + "e", + "l", + "t", + "a", + "_", + "i", + " ", + "\\", + "l", + "e", + "q", + " ", + "u", + "$", + ",", + " ", + "a", + "n", + "d", + " ", + "b", + "a", + "s", + "e", + " ", + "t", + "e", + "r", + "m", + "s", + " ", + "a", + "r", + "e", + " ", + "m", + "u", + "l", + "t", + "i", + "p", + "l", + "i", + "e", + "d", + " ", + "b", + "y", + " ", + "$", + "u", + "$", + "\n", + "-", + " ", + "*", + "*", + "S", + "O", + "S", + "2", + ":", + "*", + "*", + " ", + "c", + "o", + "n", + "v", + "e", + "x", + "i", + "t", + "y", + " ", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + " ", + "b", + "e", + "c", + "o", + "m", + "e", + "s", + " ", + "$", + "\\", + "s", + "u", + "m", + " ", + "\\", + "l", + "a", + "m", + "b", + "d", + "a", + "_", + "i", + " ", + "=", + " ", + "u", + "$", + "\n", + "-", + " ", + "*", + "*", + "D", + "i", + "s", + "j", + "u", + "n", + "c", + "t", + "i", + "v", + "e", + ":", + "*", + "*", + " ", + "s", + "e", + "g", + "m", + "e", + "n", + "t", + " ", + "s", + "e", + "l", + "e", + "c", + "t", + "i", + "o", + "n", + " ", + "b", + "e", + "c", + "o", + "m", + "e", + "s", + " ", + "$", + "\\", + "s", + "u", + "m", + " ", + "z", + "_", + "k", + " ", + "=", + " ", + "u", + "$", + "\n", + "\n", + "T", + "h", + "i", + "s", + " ", + "i", + "s", + " ", + "t", + "h", + "e", + " ", + "o", + "n", + "l", + "y", + " ", + "g", + "a", + "t", + "i", + "n", + "g", + " ", + "b", + "e", + "h", + "a", + "v", + "i", + "o", + "r", + " ", + "e", + "x", + "p", + "r", + "e", + "s", + "s", + "i", + "b", + "l", + "e", + " ", + "w", + "i", + "t", + "h", + " ", + "p", + "u", + "r", + "e", + " ", + "l", + "i", + "n", + "e", + "a", + "r", + " ", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + ".", + "\n", + "S", + "e", + "l", + "e", + "c", + "t", + "i", + "v", + "e", + "l", + "y", + " ", + "*", + "r", + "e", + "l", + "a", + "x", + "i", + "n", + "g", + "*", + " ", + "t", + "h", + "e", + " ", + "P", + "W", + "L", + " ", + "(", + "l", + "e", + "t", + "t", + "i", + "n", + "g", + " ", + "x", + ",", + " ", + "y", + " ", + "f", + "l", + "o", + "a", + "t", + " ", + "f", + "r", + "e", + "e", + "l", + "y", + " ", + "w", + "h", + "e", + "n", + " ", + "o", + "f", + "f", + ")", + " ", + "w", + "o", + "u", + "l", + "d", + "\n", + "r", + "e", + "q", + "u", + "i", + "r", + "e", + " ", + "b", + "i", + "g", + "-", + "M", + " ", + "o", + "r", + " ", + "i", + "n", + "d", + "i", + "c", + "a", + "t", + "o", + "r", + " ", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "." + ] }, { "cell_type": "code", @@ -736,12 +3723,700 @@ { "cell_type": "markdown", "metadata": {}, - "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": [ + "A", + "t", + " ", + "*", + "*", + "t", + "=", + "1", + "*", + "*", + ",", + " ", + "d", + "e", + "m", + "a", + "n", + "d", + " ", + "(", + "1", + "5", + " ", + "M", + "W", + ")", + " ", + "i", + "s", + " ", + "b", + "e", + "l", + "o", + "w", + " ", + "t", + "h", + "e", + " ", + "m", + "i", + "n", + "i", + "m", + "u", + "m", + " ", + "l", + "o", + "a", + "d", + " ", + "(", + "3", + "0", + " ", + "M", + "W", + ")", + ".", + " ", + "T", + "h", + "e", + " ", + "s", + "o", + "l", + "v", + "e", + "r", + "\n", + "k", + "e", + "e", + "p", + "s", + " ", + "t", + "h", + "e", + " ", + "u", + "n", + "i", + "t", + " ", + "o", + "f", + "f", + " ", + "(", + "`", + "c", + "o", + "m", + "m", + "i", + "t", + "=", + "0", + "`", + ")", + ",", + " ", + "s", + "o", + " ", + "`", + "p", + "o", + "w", + "e", + "r", + "=", + "0", + "`", + " ", + "a", + "n", + "d", + " ", + "`", + "f", + "u", + "e", + "l", + "=", + "0", + "`", + " ", + "\u2014", + " ", + "t", + "h", + "e", + " ", + "`", + "a", + "c", + "t", + "i", + "v", + "e", + "`", + "\n", + "p", + "a", + "r", + "a", + "m", + "e", + "t", + "e", + "r", + " ", + "e", + "n", + "f", + "o", + "r", + "c", + "e", + "s", + " ", + "t", + "h", + "i", + "s", + ".", + " ", + "D", + "e", + "m", + "a", + "n", + "d", + " ", + "i", + "s", + " ", + "m", + "e", + "t", + " ", + "b", + "y", + " ", + "t", + "h", + "e", + " ", + "b", + "a", + "c", + "k", + "u", + "p", + " ", + "s", + "o", + "u", + "r", + "c", + "e", + ".", + "\n", + "\n", + "A", + "t", + " ", + "*", + "*", + "t", + "=", + "2", + "*", + "*", + " ", + "a", + "n", + "d", + " ", + "*", + "*", + "t", + "=", + "3", + "*", + "*", + ",", + " ", + "t", + "h", + "e", + " ", + "u", + "n", + "i", + "t", + " ", + "c", + "o", + "m", + "m", + "i", + "t", + "s", + " ", + "a", + "n", + "d", + " ", + "o", + "p", + "e", + "r", + "a", + "t", + "e", + "s", + " ", + "o", + "n", + " ", + "t", + "h", + "e", + " ", + "P", + "W", + "L", + " ", + "c", + "u", + "r", + "v", + "e", + "." + ] }, { "cell_type": "markdown", "metadata": {}, - "source": "## 7. N-variable formulation -- CHP plant\n\nWhen multiple outputs are linked through shared operating points (e.g., a\ncombined heat and power plant where power, fuel, and heat are all functions\nof a single loading parameter), use the **N-variable** API.\n\nInstead of separate x/y breakpoints, you pass a dictionary of expressions\nand a single breakpoint DataArray whose coordinates match the dictionary keys." + "source": [ + "#", + "#", + " ", + "7", + ".", + " ", + "N", + "-", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + " ", + "f", + "o", + "r", + "m", + "u", + "l", + "a", + "t", + "i", + "o", + "n", + " ", + "-", + "-", + " ", + "C", + "H", + "P", + " ", + "p", + "l", + "a", + "n", + "t", + "\n", + "\n", + "W", + "h", + "e", + "n", + " ", + "m", + "u", + "l", + "t", + "i", + "p", + "l", + "e", + " ", + "o", + "u", + "t", + "p", + "u", + "t", + "s", + " ", + "a", + "r", + "e", + " ", + "l", + "i", + "n", + "k", + "e", + "d", + " ", + "t", + "h", + "r", + "o", + "u", + "g", + "h", + " ", + "s", + "h", + "a", + "r", + "e", + "d", + " ", + "o", + "p", + "e", + "r", + "a", + "t", + "i", + "n", + "g", + " ", + "p", + "o", + "i", + "n", + "t", + "s", + " ", + "(", + "e", + ".", + "g", + ".", + ",", + " ", + "a", + "\n", + "c", + "o", + "m", + "b", + "i", + "n", + "e", + "d", + " ", + "h", + "e", + "a", + "t", + " ", + "a", + "n", + "d", + " ", + "p", + "o", + "w", + "e", + "r", + " ", + "p", + "l", + "a", + "n", + "t", + " ", + "w", + "h", + "e", + "r", + "e", + " ", + "p", + "o", + "w", + "e", + "r", + ",", + " ", + "f", + "u", + "e", + "l", + ",", + " ", + "a", + "n", + "d", + " ", + "h", + "e", + "a", + "t", + " ", + "a", + "r", + "e", + " ", + "a", + "l", + "l", + " ", + "f", + "u", + "n", + "c", + "t", + "i", + "o", + "n", + "s", + "\n", + "o", + "f", + " ", + "a", + " ", + "s", + "i", + "n", + "g", + "l", + "e", + " ", + "l", + "o", + "a", + "d", + "i", + "n", + "g", + " ", + "p", + "a", + "r", + "a", + "m", + "e", + "t", + "e", + "r", + ")", + ",", + " ", + "u", + "s", + "e", + " ", + "t", + "h", + "e", + " ", + "*", + "*", + "N", + "-", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "*", + "*", + " ", + "A", + "P", + "I", + ".", + "\n", + "\n", + "I", + "n", + "s", + "t", + "e", + "a", + "d", + " ", + "o", + "f", + " ", + "s", + "e", + "p", + "a", + "r", + "a", + "t", + "e", + " ", + "x", + "/", + "y", + " ", + "b", + "r", + "e", + "a", + "k", + "p", + "o", + "i", + "n", + "t", + "s", + ",", + " ", + "y", + "o", + "u", + " ", + "p", + "a", + "s", + "s", + " ", + "a", + " ", + "d", + "i", + "c", + "t", + "i", + "o", + "n", + "a", + "r", + "y", + " ", + "o", + "f", + " ", + "e", + "x", + "p", + "r", + "e", + "s", + "s", + "i", + "o", + "n", + "s", + "\n", + "a", + "n", + "d", + " ", + "a", + " ", + "s", + "i", + "n", + "g", + "l", + "e", + " ", + "b", + "r", + "e", + "a", + "k", + "p", + "o", + "i", + "n", + "t", + " ", + "D", + "a", + "t", + "a", + "A", + "r", + "r", + "a", + "y", + " ", + "w", + "h", + "o", + "s", + "e", + " ", + "c", + "o", + "o", + "r", + "d", + "i", + "n", + "a", + "t", + "e", + "s", + " ", + "m", + "a", + "t", + "c", + "h", + " ", + "t", + "h", + "e", + " ", + "d", + "i", + "c", + "t", + "i", + "o", + "n", + "a", + "r", + "y", + " ", + "k", + "e", + "y", + "s", + "." + ] }, { "cell_type": "code", @@ -792,7 +4467,7 @@ " method=\"sos2\",\n", ")\n", "\n", - "# Fixed power dispatch determines the operating point — fuel and heat follow\n", + "# Fixed power dispatch determines the operating point \u2014 fuel and heat follow\n", "power_dispatch = xr.DataArray([20, 60, 90], coords=[time])\n", "m7.add_constraints(power == power_dispatch, name=\"power_dispatch\")\n", "\n", diff --git a/linopy/__init__.py b/linopy/__init__.py index 16d9f1cd..18844df2 100644 --- a/linopy/__init__.py +++ b/linopy/__init__.py @@ -18,7 +18,7 @@ from linopy.constraints import Constraint, Constraints from linopy.expressions import LinearExpression, QuadraticExpression, merge from linopy.io import read_netcdf -from linopy.linearization import piecewise_envelope +from linopy.linearization import piecewise_tangents from linopy.model import Model, Variable, Variables, available_solvers from linopy.objective import Objective from linopy.piecewise import breakpoints, segments, slopes_to_points @@ -45,7 +45,7 @@ "Variables", "available_solvers", "breakpoints", - "piecewise_envelope", + "piecewise_tangents", "segments", "slopes_to_points", "align", diff --git a/linopy/linearization.py b/linopy/linearization.py index 369325d9..688f22f3 100644 --- a/linopy/linearization.py +++ b/linopy/linearization.py @@ -20,7 +20,7 @@ from linopy.types import LinExprLike -def piecewise_envelope( +def piecewise_tangents( x: LinExprLike, x_points: BreaksLike, y_points: BreaksLike, @@ -37,7 +37,7 @@ def piecewise_envelope( .. code-block:: python - envelope = piecewise_envelope(power, x_pts, y_pts) + envelope = piecewise_tangents(power, x_pts, y_pts) m.add_constraints(fuel <= envelope) # upper bound (concave f) m.add_constraints(fuel >= envelope) # lower bound (convex f) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index d08d1bcf..7fe62ffb 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -763,7 +763,7 @@ def add_piecewise_constraints( expressions. For inequality constraints (y <= f(x) or y >= f(x)), use - :func:`~linopy.linearization.piecewise_envelope` with regular + :func:`~linopy.linearization.piecewise_tangents` with regular ``add_constraints`` instead. Example:: diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 0f9a2b48..5b53e16f 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -13,7 +13,7 @@ Model, available_solvers, breakpoints, - piecewise_envelope, + piecewise_tangents, segments, slopes_to_points, ) @@ -366,33 +366,33 @@ def test_basic_variable(self) -> None: """Envelope from a Variable produces a LinearExpression with seg dim.""" m = Model() x = m.add_variables(name="x", lower=0, upper=100) - env = piecewise_envelope(x, [0, 50, 100], [0, 40, 60]) + env = piecewise_tangents(x, [0, 50, 100], [0, 40, 60]) assert LP_SEG_DIM in env.dims def test_basic_linexpr(self) -> None: """Envelope from a LinearExpression works too.""" m = Model() x = m.add_variables(name="x", lower=0, upper=100) - env = piecewise_envelope(1 * x, [0, 50, 100], [0, 40, 60]) + env = piecewise_tangents(1 * x, [0, 50, 100], [0, 40, 60]) assert LP_SEG_DIM in env.dims def test_segment_count(self) -> None: """Number of segments = number of breakpoints - 1.""" m = Model() x = m.add_variables(name="x") - env = piecewise_envelope(x, [0, 50, 100], [0, 40, 60]) + env = piecewise_tangents(x, [0, 50, 100], [0, 40, 60]) assert env.sizes[LP_SEG_DIM] == 2 def test_invalid_x_type_raises(self) -> None: with pytest.raises(TypeError, match="must be a Variable or LinearExpression"): - piecewise_envelope(42, [0, 50, 100], [0, 40, 60]) # type: ignore + piecewise_tangents(42, [0, 50, 100], [0, 40, 60]) # type: ignore def test_concave_le_constraint(self) -> None: """Using envelope with <= constraint creates regular constraints.""" m = Model() x = m.add_variables(name="x", lower=0, upper=100) y = m.add_variables(name="y") - env = piecewise_envelope(x, [0, 50, 100], [0, 40, 60]) + env = piecewise_tangents(x, [0, 50, 100], [0, 40, 60]) m.add_constraints(y <= env, name="pwl") assert "pwl" in m.constraints @@ -401,7 +401,7 @@ def test_convex_ge_constraint(self) -> None: m = Model() x = m.add_variables(name="x", lower=0, upper=100) y = m.add_variables(name="y") - env = piecewise_envelope(x, [0, 50, 100], [0, 10, 60]) + env = piecewise_tangents(x, [0, 50, 100], [0, 10, 60]) m.add_constraints(y >= env, name="pwl") assert "pwl" in m.constraints @@ -411,7 +411,7 @@ def test_dataarray_breakpoints(self) -> None: x = m.add_variables(name="x") x_pts = xr.DataArray([0, 50, 100], dims=[BREAKPOINT_DIM]) y_pts = xr.DataArray([0, 40, 60], dims=[BREAKPOINT_DIM]) - env = piecewise_envelope(x, x_pts, y_pts) + env = piecewise_tangents(x, x_pts, y_pts) assert LP_SEG_DIM in env.dims @@ -886,7 +886,7 @@ def test_concave_le(self, solver_name: str) -> None: x = m.add_variables(lower=0, upper=100, name="x") y = m.add_variables(name="y") # Concave: [0,0],[50,40],[100,60] - env = piecewise_envelope(x, [0, 50, 100], [0, 40, 60]) + env = piecewise_tangents(x, [0, 50, 100], [0, 40, 60]) m.add_constraints(y <= env, name="pwl") m.add_constraints(x <= 75, name="x_max") m.add_constraints(x >= 0, name="x_lo") @@ -903,7 +903,7 @@ def test_convex_ge(self, solver_name: str) -> None: x = m.add_variables(lower=0, upper=100, name="x") y = m.add_variables(name="y") # Convex: [0,0],[50,10],[100,60] - env = piecewise_envelope(x, [0, 50, 100], [0, 10, 60]) + env = piecewise_tangents(x, [0, 50, 100], [0, 10, 60]) m.add_constraints(y >= env, name="pwl") m.add_constraints(x >= 25, name="x_min") m.add_objective(y) @@ -919,7 +919,7 @@ def test_slopes_equivalence(self, solver_name: str) -> None: m1 = Model() x1 = m1.add_variables(lower=0, upper=100, name="x") y1 = m1.add_variables(name="y") - env1 = piecewise_envelope(x1, [0, 50, 100], [0, 40, 60]) + env1 = piecewise_tangents(x1, [0, 50, 100], [0, 40, 60]) m1.add_constraints(y1 <= env1, name="pwl") m1.add_constraints(x1 <= 75, name="x_max") m1.add_constraints(x1 >= 0, name="x_lo") @@ -930,7 +930,7 @@ def test_slopes_equivalence(self, solver_name: str) -> None: m2 = Model() x2 = m2.add_variables(lower=0, upper=100, name="x") y2 = m2.add_variables(name="y") - env2 = piecewise_envelope( + env2 = piecewise_tangents( x2, [0, 50, 100], breakpoints(slopes=[0.8, 0.4], x_points=[0, 50, 100], y0=0), From 779aab06f15891e9ec5ab222d79fc96a167de349 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:15:10 +0200 Subject: [PATCH 09/30] =?UTF-8?q?rename=20to=20tangent=5Flines=20=E2=80=94?= =?UTF-8?q?=20not=20piecewise,=20just=20linear=20expressions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/piecewise-linear-constraints.rst | 16 ++++++++-------- linopy/__init__.py | 4 ++-- linopy/linearization.py | 4 ++-- linopy/piecewise.py | 2 +- test/test_piecewise_constraints.py | 24 ++++++++++++------------ 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 5f435a7a..7f7a6010 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -9,7 +9,7 @@ production functions within a linear programming framework. Use :py:meth:`~linopy.model.Model.add_piecewise_constraints` to add piecewise equality constraints to a model. For inequality constraints (upper/lower -envelopes), use :func:`~linopy.linearization.piecewise_tangents`. +envelopes), use :func:`~linopy.linearization.tangent_lines`. .. contents:: :local: @@ -46,12 +46,12 @@ lie on the interpolated breakpoint curve. **Envelope (inequality):** For inequality constraints such as :math:`y \le f(x)` or :math:`y \ge f(x)`, use -:func:`~linopy.linearization.piecewise_tangents` to obtain tangent-line +:func:`~linopy.linearization.tangent_lines` to obtain tangent-line expressions and add them as regular constraints: .. code-block:: python - envelope = linopy.piecewise_tangents(power, x_pts, y_pts) + envelope = linopy.tangent_lines(power, x_pts, y_pts) m.add_constraints(fuel <= envelope) # upper bound (concave f) m.add_constraints(fuel >= envelope) # lower bound (convex f) @@ -133,7 +133,7 @@ same N-variable code path. Piecewise Envelope (inequality) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -For inequality constraints, use :func:`~linopy.linearization.piecewise_tangents` +For inequality constraints, use :func:`~linopy.linearization.tangent_lines` instead of ``add_piecewise_constraints``. The envelope function computes tangent-line expressions for each segment --- no auxiliary variables are created: @@ -145,7 +145,7 @@ Use the result in a regular constraint: .. code-block:: python - envelope = linopy.piecewise_tangents(power, x_pts, y_pts) + envelope = linopy.tangent_lines(power, x_pts, y_pts) m.add_constraints(fuel <= envelope) # upper bound (concave f) m.add_constraints(fuel >= envelope) # lower bound (convex f) @@ -208,7 +208,7 @@ Pass ``method="auto"`` (the default) and linopy picks the best formulation: - **Equality + monotonic breakpoints** -> incremental - Otherwise -> SOS2 - Disjunctive (segments) -> always SOS2 with binary selection -- **Inequality** -> use ``piecewise_tangents`` + regular constraints +- **Inequality** -> use ``tangent_lines`` + regular constraints .. list-table:: :header-rows: 1 @@ -261,11 +261,11 @@ Inequality via envelope .. code-block:: python # fuel <= f(power): y bounded above (concave function) - envelope = linopy.piecewise_tangents(power, x_pts, y_pts) + envelope = linopy.tangent_lines(power, x_pts, y_pts) m.add_constraints(fuel <= envelope) # fuel >= f(power): y bounded below (convex function) - envelope = linopy.piecewise_tangents(power, x_pts, y_pts) + envelope = linopy.tangent_lines(power, x_pts, y_pts) m.add_constraints(fuel >= envelope) N-variable linking diff --git a/linopy/__init__.py b/linopy/__init__.py index 18844df2..d943c30c 100644 --- a/linopy/__init__.py +++ b/linopy/__init__.py @@ -18,7 +18,7 @@ from linopy.constraints import Constraint, Constraints from linopy.expressions import LinearExpression, QuadraticExpression, merge from linopy.io import read_netcdf -from linopy.linearization import piecewise_tangents +from linopy.linearization import tangent_lines from linopy.model import Model, Variable, Variables, available_solvers from linopy.objective import Objective from linopy.piecewise import breakpoints, segments, slopes_to_points @@ -45,7 +45,7 @@ "Variables", "available_solvers", "breakpoints", - "piecewise_tangents", + "tangent_lines", "segments", "slopes_to_points", "align", diff --git a/linopy/linearization.py b/linopy/linearization.py index 688f22f3..a6aa25f4 100644 --- a/linopy/linearization.py +++ b/linopy/linearization.py @@ -20,7 +20,7 @@ from linopy.types import LinExprLike -def piecewise_tangents( +def tangent_lines( x: LinExprLike, x_points: BreaksLike, y_points: BreaksLike, @@ -37,7 +37,7 @@ def piecewise_tangents( .. code-block:: python - envelope = piecewise_tangents(power, x_pts, y_pts) + envelope = tangent_lines(power, x_pts, y_pts) m.add_constraints(fuel <= envelope) # upper bound (concave f) m.add_constraints(fuel >= envelope) # lower bound (convex f) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 7fe62ffb..cb013d80 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -763,7 +763,7 @@ def add_piecewise_constraints( expressions. For inequality constraints (y <= f(x) or y >= f(x)), use - :func:`~linopy.linearization.piecewise_tangents` with regular + :func:`~linopy.linearization.tangent_lines` with regular ``add_constraints`` instead. Example:: diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 5b53e16f..6b0e46e9 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -13,9 +13,9 @@ Model, available_solvers, breakpoints, - piecewise_tangents, segments, slopes_to_points, + tangent_lines, ) from linopy.constants import ( BREAKPOINT_DIM, @@ -366,33 +366,33 @@ def test_basic_variable(self) -> None: """Envelope from a Variable produces a LinearExpression with seg dim.""" m = Model() x = m.add_variables(name="x", lower=0, upper=100) - env = piecewise_tangents(x, [0, 50, 100], [0, 40, 60]) + env = tangent_lines(x, [0, 50, 100], [0, 40, 60]) assert LP_SEG_DIM in env.dims def test_basic_linexpr(self) -> None: """Envelope from a LinearExpression works too.""" m = Model() x = m.add_variables(name="x", lower=0, upper=100) - env = piecewise_tangents(1 * x, [0, 50, 100], [0, 40, 60]) + env = tangent_lines(1 * x, [0, 50, 100], [0, 40, 60]) assert LP_SEG_DIM in env.dims def test_segment_count(self) -> None: """Number of segments = number of breakpoints - 1.""" m = Model() x = m.add_variables(name="x") - env = piecewise_tangents(x, [0, 50, 100], [0, 40, 60]) + env = tangent_lines(x, [0, 50, 100], [0, 40, 60]) assert env.sizes[LP_SEG_DIM] == 2 def test_invalid_x_type_raises(self) -> None: with pytest.raises(TypeError, match="must be a Variable or LinearExpression"): - piecewise_tangents(42, [0, 50, 100], [0, 40, 60]) # type: ignore + tangent_lines(42, [0, 50, 100], [0, 40, 60]) # type: ignore def test_concave_le_constraint(self) -> None: """Using envelope with <= constraint creates regular constraints.""" m = Model() x = m.add_variables(name="x", lower=0, upper=100) y = m.add_variables(name="y") - env = piecewise_tangents(x, [0, 50, 100], [0, 40, 60]) + env = tangent_lines(x, [0, 50, 100], [0, 40, 60]) m.add_constraints(y <= env, name="pwl") assert "pwl" in m.constraints @@ -401,7 +401,7 @@ def test_convex_ge_constraint(self) -> None: m = Model() x = m.add_variables(name="x", lower=0, upper=100) y = m.add_variables(name="y") - env = piecewise_tangents(x, [0, 50, 100], [0, 10, 60]) + env = tangent_lines(x, [0, 50, 100], [0, 10, 60]) m.add_constraints(y >= env, name="pwl") assert "pwl" in m.constraints @@ -411,7 +411,7 @@ def test_dataarray_breakpoints(self) -> None: x = m.add_variables(name="x") x_pts = xr.DataArray([0, 50, 100], dims=[BREAKPOINT_DIM]) y_pts = xr.DataArray([0, 40, 60], dims=[BREAKPOINT_DIM]) - env = piecewise_tangents(x, x_pts, y_pts) + env = tangent_lines(x, x_pts, y_pts) assert LP_SEG_DIM in env.dims @@ -886,7 +886,7 @@ def test_concave_le(self, solver_name: str) -> None: x = m.add_variables(lower=0, upper=100, name="x") y = m.add_variables(name="y") # Concave: [0,0],[50,40],[100,60] - env = piecewise_tangents(x, [0, 50, 100], [0, 40, 60]) + env = tangent_lines(x, [0, 50, 100], [0, 40, 60]) m.add_constraints(y <= env, name="pwl") m.add_constraints(x <= 75, name="x_max") m.add_constraints(x >= 0, name="x_lo") @@ -903,7 +903,7 @@ def test_convex_ge(self, solver_name: str) -> None: x = m.add_variables(lower=0, upper=100, name="x") y = m.add_variables(name="y") # Convex: [0,0],[50,10],[100,60] - env = piecewise_tangents(x, [0, 50, 100], [0, 10, 60]) + env = tangent_lines(x, [0, 50, 100], [0, 10, 60]) m.add_constraints(y >= env, name="pwl") m.add_constraints(x >= 25, name="x_min") m.add_objective(y) @@ -919,7 +919,7 @@ def test_slopes_equivalence(self, solver_name: str) -> None: m1 = Model() x1 = m1.add_variables(lower=0, upper=100, name="x") y1 = m1.add_variables(name="y") - env1 = piecewise_tangents(x1, [0, 50, 100], [0, 40, 60]) + env1 = tangent_lines(x1, [0, 50, 100], [0, 40, 60]) m1.add_constraints(y1 <= env1, name="pwl") m1.add_constraints(x1 <= 75, name="x_max") m1.add_constraints(x1 >= 0, name="x_lo") @@ -930,7 +930,7 @@ def test_slopes_equivalence(self, solver_name: str) -> None: m2 = Model() x2 = m2.add_variables(lower=0, upper=100, name="x") y2 = m2.add_variables(name="y") - env2 = piecewise_tangents( + env2 = tangent_lines( x2, [0, 50, 100], breakpoints(slopes=[0.8, 0.4], x_points=[0, 50, 100], y0=0), From 5d6962539eb01f784e3c818889a27d15b59cdee5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:26:45 +0200 Subject: [PATCH 10/30] refac: move tangent_lines into piecewise.py, remove linearization.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single function doesn't justify a separate module. tangent_lines lives next to breakpoints() and segments() — all stateless helpers for the piecewise workflow. Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/piecewise-linear-constraints.rst | 6 +- linopy/__init__.py | 3 +- linopy/linearization.py | 99 ---------------------------- linopy/piecewise.py | 84 ++++++++++++++++++++++- 4 files changed, 87 insertions(+), 105 deletions(-) delete mode 100644 linopy/linearization.py diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 7f7a6010..41fc0df4 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -9,7 +9,7 @@ production functions within a linear programming framework. Use :py:meth:`~linopy.model.Model.add_piecewise_constraints` to add piecewise equality constraints to a model. For inequality constraints (upper/lower -envelopes), use :func:`~linopy.linearization.tangent_lines`. +envelopes), use :func:`~linopy.piecewise.tangent_lines`. .. contents:: :local: @@ -46,7 +46,7 @@ lie on the interpolated breakpoint curve. **Envelope (inequality):** For inequality constraints such as :math:`y \le f(x)` or :math:`y \ge f(x)`, use -:func:`~linopy.linearization.tangent_lines` to obtain tangent-line +:func:`~linopy.piecewise.tangent_lines` to obtain tangent-line expressions and add them as regular constraints: .. code-block:: python @@ -133,7 +133,7 @@ same N-variable code path. Piecewise Envelope (inequality) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -For inequality constraints, use :func:`~linopy.linearization.tangent_lines` +For inequality constraints, use :func:`~linopy.piecewise.tangent_lines` instead of ``add_piecewise_constraints``. The envelope function computes tangent-line expressions for each segment --- no auxiliary variables are created: diff --git a/linopy/__init__.py b/linopy/__init__.py index d943c30c..aa14b767 100644 --- a/linopy/__init__.py +++ b/linopy/__init__.py @@ -18,10 +18,9 @@ from linopy.constraints import Constraint, Constraints from linopy.expressions import LinearExpression, QuadraticExpression, merge from linopy.io import read_netcdf -from linopy.linearization import tangent_lines from linopy.model import Model, Variable, Variables, available_solvers from linopy.objective import Objective -from linopy.piecewise import breakpoints, segments, slopes_to_points +from linopy.piecewise import breakpoints, segments, slopes_to_points, tangent_lines from linopy.remote import RemoteHandler try: diff --git a/linopy/linearization.py b/linopy/linearization.py deleted file mode 100644 index a6aa25f4..00000000 --- a/linopy/linearization.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -Linearization utilities for approximating nonlinear functions. - -These helpers return regular :class:`~linopy.expressions.LinearExpression` -objects --- no auxiliary variables or special constraint types are created. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import numpy as np -from xarray import DataArray - -from linopy.constants import BREAKPOINT_DIM, LP_SEG_DIM -from linopy.piecewise import BreaksLike, _coerce_breaks - -if TYPE_CHECKING: - from linopy.expressions import LinearExpression - from linopy.types import LinExprLike - - -def tangent_lines( - x: LinExprLike, - x_points: BreaksLike, - y_points: BreaksLike, -) -> LinearExpression: - r""" - Compute tangent-line expressions for a piecewise linear function. - - Returns a :class:`~linopy.expressions.LinearExpression` with an extra - segment dimension. Each element along the segment dimension is the - tangent line of one segment: :math:`m_k \cdot x + c_k`. - - Use the result in a regular constraint to create an upper or lower - envelope: - - .. code-block:: python - - envelope = tangent_lines(power, x_pts, y_pts) - m.add_constraints(fuel <= envelope) # upper bound (concave f) - m.add_constraints(fuel >= envelope) # lower bound (convex f) - - No auxiliary variables are created --- the result is purely linear. - - Parameters - ---------- - x : Variable or LinearExpression - The input expression. - x_points : BreaksLike - Breakpoint x-coordinates (must be strictly increasing). - y_points : BreaksLike - Breakpoint y-coordinates. - - Returns - ------- - LinearExpression - Expression with an additional ``_breakpoint_seg`` dimension - (one entry per segment). - """ - from linopy.expressions import LinearExpression - from linopy.variables import Variable - - if not isinstance(x_points, DataArray): - x_points = _coerce_breaks(x_points) - if not isinstance(y_points, DataArray): - y_points = _coerce_breaks(y_points) - - dx = x_points.diff(BREAKPOINT_DIM) - dy = y_points.diff(BREAKPOINT_DIM) - slopes = dy / dx - - n_seg = slopes.sizes[BREAKPOINT_DIM] - seg_index = np.arange(n_seg) - - slopes = slopes.rename({BREAKPOINT_DIM: LP_SEG_DIM}) - slopes[LP_SEG_DIM] = seg_index - - x_base = x_points.isel({BREAKPOINT_DIM: slice(None, -1)}).rename( - {BREAKPOINT_DIM: LP_SEG_DIM} - ) - y_base = y_points.isel({BREAKPOINT_DIM: slice(None, -1)}).rename( - {BREAKPOINT_DIM: LP_SEG_DIM} - ) - x_base[LP_SEG_DIM] = seg_index - y_base[LP_SEG_DIM] = seg_index - - # tangent_k(x) = slopes_k * (x - x_base_k) + y_base_k - # = slopes_k * x + (y_base_k - slopes_k * x_base_k) - intercepts = y_base - slopes * x_base - - if isinstance(x, Variable): - x_expr = x.to_linexpr() - elif isinstance(x, LinearExpression): - x_expr = x - else: - raise TypeError(f"x must be a Variable or LinearExpression, got {type(x)}") - - return slopes * x_expr + intercepts diff --git a/linopy/piecewise.py b/linopy/piecewise.py index cb013d80..a48fa604 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -359,6 +359,88 @@ def segments( return _coerce_segments(values, dim) +def tangent_lines( + x: LinExprLike, + x_points: BreaksLike, + y_points: BreaksLike, +) -> LinearExpression: + r""" + Compute tangent-line expressions for a piecewise linear function. + + Returns a :class:`~linopy.expressions.LinearExpression` with an extra + segment dimension. Each element along the segment dimension is the + tangent line of one segment: :math:`m_k \cdot x + c_k`. + + Use the result in a regular constraint to create an upper or lower + bound: + + .. code-block:: python + + t = tangent_lines(power, x_pts, y_pts) + m.add_constraints(fuel <= t) # upper bound (concave f) + m.add_constraints(fuel >= t) # lower bound (convex f) + + No auxiliary variables are created — the result is purely linear. + + Parameters + ---------- + x : Variable or LinearExpression + The input expression. + x_points : BreaksLike + Breakpoint x-coordinates (must be strictly increasing). + y_points : BreaksLike + Breakpoint y-coordinates. + + Returns + ------- + LinearExpression + Expression with an additional ``_breakpoint_seg`` dimension + (one entry per segment). + """ + from linopy.expressions import LinearExpression as LinExpr + from linopy.variables import Variable + + if not isinstance(x_points, DataArray): + x_points = _coerce_breaks(x_points) + if not isinstance(y_points, DataArray): + y_points = _coerce_breaks(y_points) + + dx = x_points.diff(BREAKPOINT_DIM) + dy = y_points.diff(BREAKPOINT_DIM) + slopes = dy / dx + + n_seg = slopes.sizes[BREAKPOINT_DIM] + seg_index = np.arange(n_seg) + + slopes = slopes.rename({BREAKPOINT_DIM: LP_SEG_DIM}) + slopes[LP_SEG_DIM] = seg_index + + x_base = x_points.isel({BREAKPOINT_DIM: slice(None, -1)}).rename( + {BREAKPOINT_DIM: LP_SEG_DIM} + ) + y_base = y_points.isel({BREAKPOINT_DIM: slice(None, -1)}).rename( + {BREAKPOINT_DIM: LP_SEG_DIM} + ) + x_base[LP_SEG_DIM] = seg_index + y_base[LP_SEG_DIM] = seg_index + + intercepts = y_base - slopes * x_base + + if isinstance(x, Variable): + x_expr = x.to_linexpr() + elif isinstance(x, LinExpr): + x_expr = x + else: + raise TypeError(f"x must be a Variable or LinearExpression, got {type(x)}") + + return slopes * x_expr + intercepts + + +# --------------------------------------------------------------------------- +# Internal validation +# --------------------------------------------------------------------------- + + def _validate_xy_points(x_points: DataArray, y_points: DataArray) -> bool: """Validate x/y breakpoint arrays and return whether formulation is disjunctive.""" if BREAKPOINT_DIM not in x_points.dims: @@ -763,7 +845,7 @@ def add_piecewise_constraints( expressions. For inequality constraints (y <= f(x) or y >= f(x)), use - :func:`~linopy.linearization.tangent_lines` with regular + :func:`~linopy.piecewise.tangent_lines` with regular ``add_constraints`` instead. Example:: From 70dfbcbad332291ec30898930d69930e9d8402d1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:30:33 +0200 Subject: [PATCH 11/30] =?UTF-8?q?docs:=20clarify=20equality=20vs=20inequal?= =?UTF-8?q?ity=20=E2=80=94=20when=20to=20use=20what?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add prominent section explaining the fundamental difference: - add_piecewise_constraints: exact equality, needs aux variables - tangent_lines: one-sided bounds, pure LP, no aux variables - tangent_lines with == is infeasible (overconstrained) Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/piecewise-linear-constraints.rst | 105 ++++++++++++++++++++------- 1 file changed, 80 insertions(+), 25 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 41fc0df4..7fc5e0f9 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -7,15 +7,80 @@ Piecewise linear (PWL) constraints approximate nonlinear functions as connected linear segments, allowing you to model cost curves, efficiency curves, or production functions within a linear programming framework. -Use :py:meth:`~linopy.model.Model.add_piecewise_constraints` to add piecewise -equality constraints to a model. For inequality constraints (upper/lower -envelopes), use :func:`~linopy.piecewise.tangent_lines`. +linopy offers two tools: + +- :py:meth:`~linopy.model.Model.add_piecewise_constraints` --- + exact equality on the piecewise curve (creates auxiliary variables). +- :func:`~linopy.piecewise.tangent_lines` --- + one-sided bounds via tangent lines (pure LP, no auxiliary variables). .. contents:: :local: :depth: 2 +Equality vs Inequality +---------------------- + +linopy provides two distinct tools for piecewise linear modelling. +Understanding when to use which is the key design decision. + +``add_piecewise_constraints`` — exact equality on the curve +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use this when variables must lie **exactly on** the piecewise curve +(:math:`y = f(x)`). It creates auxiliary variables (lambda weights or +delta fractions) and combinatorial constraints (SOS2 or binary indicators) +to enforce that the operating point is interpolated between adjacent +breakpoints. + +.. code-block:: python + + m.add_piecewise_constraints( + x=power, + y=fuel, + x_points=x_pts, + y_points=y_pts, + ) + +This is the only way to enforce exact piecewise equality. It requires +a MIP or SOS2-capable solver. + +``tangent_lines`` — one-sided bound, pure LP +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use this when a variable must be **bounded above or below** by the +piecewise curve (:math:`y \le f(x)` or :math:`y \ge f(x)`). It +computes one tangent line per segment and returns them as a regular +:class:`~linopy.expressions.LinearExpression` with a segment dimension. +**No auxiliary variables are created.** + +.. code-block:: python + + t = linopy.tangent_lines(power, x_pts, y_pts) + m.add_constraints(fuel <= t) # fuel bounded above by f(power) + m.add_constraints(fuel >= t) # fuel bounded below by f(power) + +xarray broadcasting creates one linear constraint per segment per +coordinate entry. The result is solvable by **any LP solver** --- +no SOS2, no binaries. + +.. warning:: + + ``tangent_lines`` does **not** work with equality. Writing + ``fuel == tangent_lines(...)`` would require fuel to simultaneously + satisfy every tangent line, which is infeasible except at breakpoints. + Use ``add_piecewise_constraints`` for equality. + +**When is the bound tight?** The tangent-line bound is exact (tight at +every point on the curve) when the function has the right convexity: + +- :math:`y \le f(x)` is tight when *f* is **concave** (slopes decrease) +- :math:`y \ge f(x)` is tight when *f* is **convex** (slopes increase) + +For other combinations the bound is valid but loose (a relaxation). + + Overview -------- @@ -44,17 +109,6 @@ lie on the interpolated breakpoint curve. y_points=y_pts, ) -**Envelope (inequality):** For inequality constraints such as -:math:`y \le f(x)` or :math:`y \ge f(x)`, use -:func:`~linopy.piecewise.tangent_lines` to obtain tangent-line -expressions and add them as regular constraints: - -.. code-block:: python - - envelope = linopy.tangent_lines(power, x_pts, y_pts) - m.add_constraints(fuel <= envelope) # upper bound (concave f) - m.add_constraints(fuel >= envelope) # lower bound (convex f) - Mathematical Background ----------------------- @@ -130,26 +184,27 @@ same N-variable code path. ) -Piecewise Envelope (inequality) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Tangent lines (inequality) +~~~~~~~~~~~~~~~~~~~~~~~~~~ -For inequality constraints, use :func:`~linopy.piecewise.tangent_lines` -instead of ``add_piecewise_constraints``. The envelope function computes -tangent-line expressions for each segment --- no auxiliary variables are created: +:func:`~linopy.piecewise.tangent_lines` computes the tangent line for +each segment of the piecewise function: .. math:: \text{tangent}_k(x) = m_k \cdot x + c_k \quad \text{for each segment } k -Use the result in a regular constraint: +where :math:`m_k = (y_{k+1} - y_k) / (x_{k+1} - x_k)` is the slope and +:math:`c_k = y_k - m_k \cdot x_k` is the intercept. The result is a +:class:`~linopy.expressions.LinearExpression` with a segment dimension --- +one linear expression per segment, no auxiliary variables. -.. code-block:: python +The user then adds their own constraint: - envelope = linopy.tangent_lines(power, x_pts, y_pts) - m.add_constraints(fuel <= envelope) # upper bound (concave f) - m.add_constraints(fuel >= envelope) # lower bound (convex f) +.. code-block:: python -This is solvable by any LP solver --- no SOS2 or binary variables needed. + t = linopy.tangent_lines(power, x_pts, y_pts) + m.add_constraints(fuel <= t) # one constraint per segment per timestep Formulation Methods From c57e2740652c543c83ea629240e7abd00bbdcbbc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:09:54 +0200 Subject: [PATCH 12/30] refac: tuple-based API for add_piecewise_constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace keyword-only (x=, y=, x_points=, y_points=) and dict-based (exprs=, breakpoints=) forms with a single tuple-based API: m.add_piecewise_constraints( (power, [0, 30, 60, 100]), (fuel, [0, 36, 84, 170]), ) 2-var and N-var are the same pattern — no separate convenience API. Internally stacks all breakpoints along a link dimension and uses a unified formulation path. Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/piecewise-linear-constraints.rst | 194 +- examples/piecewise-linear-constraints.ipynb | 3093 +++---------------- linopy/piecewise.py | 568 ++-- test/test_piecewise_constraints.py | 367 +-- 4 files changed, 830 insertions(+), 3392 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 7fc5e0f9..be669a26 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -22,11 +22,8 @@ linopy offers two tools: Equality vs Inequality ---------------------- -linopy provides two distinct tools for piecewise linear modelling. -Understanding when to use which is the key design decision. - -``add_piecewise_constraints`` — exact equality on the curve -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``add_piecewise_constraints`` --- exact equality on the curve +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Use this when variables must lie **exactly on** the piecewise curve (:math:`y = f(x)`). It creates auxiliary variables (lambda weights or @@ -37,17 +34,15 @@ breakpoints. .. code-block:: python m.add_piecewise_constraints( - x=power, - y=fuel, - x_points=x_pts, - y_points=y_pts, + (power, [0, 30, 60, 100]), + (fuel, [0, 36, 84, 170]), ) This is the only way to enforce exact piecewise equality. It requires a MIP or SOS2-capable solver. -``tangent_lines`` — one-sided bound, pure LP -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``tangent_lines`` --- one-sided bound, pure LP +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Use this when a variable must be **bounded above or below** by the piecewise curve (:math:`y \le f(x)` or :math:`y \ge f(x)`). It @@ -84,39 +79,37 @@ For other combinations the bound is valid but loose (a relaxation). Overview -------- -``add_piecewise_constraints`` supports two calling conventions: +``add_piecewise_constraints`` takes ``(expression, breakpoints)`` tuples as +positional arguments. All tuples share the same interpolation weights, +coupling the expressions on the same curve segment. -**N-variable (general form):** Link any number of expressions through shared -breakpoints. All expressions are symmetric --- they are jointly constrained to -lie on the interpolated breakpoint curve. +**2 variables:** .. code-block:: python m.add_piecewise_constraints( - exprs={"power": power, "fuel": fuel, "heat": heat}, - breakpoints=bp, + (power, [0, 30, 60, 100]), + (fuel, [0, 36, 84, 170]), ) -**2-variable (convenience form):** A shorthand for linking two expressions -``x`` and ``y`` via separate x/y breakpoints. +**N variables (e.g. CHP plant):** .. code-block:: python m.add_piecewise_constraints( - x=power, - y=fuel, - x_points=x_pts, - y_points=y_pts, + (power, [0, 30, 60, 100]), + (fuel, [0, 40, 85, 160]), + (heat, [0, 25, 55, 95]), ) Mathematical Background ----------------------- -Core formulation (N-variable) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Core formulation +~~~~~~~~~~~~~~~~ -The general piecewise linear formulation links *N* expressions +The piecewise linear formulation links *N* expressions :math:`e_1, e_2, \ldots, e_N` through a shared set of breakpoints. Given :math:`n+1` breakpoints :math:`B_{j,0}, B_{j,1}, \ldots, B_{j,n}` for @@ -139,50 +132,6 @@ The SOS2 constraint ensures at most two *adjacent* :math:`\lambda_i` are non-zero, so every expression is interpolated within the same segment. All expressions share the same :math:`\lambda` weights, which is what couples them. -**Example:** A CHP plant with fuel input, electrical output, and heat output at -four operating points: - -.. code-block:: python - - bp = linopy.breakpoints( - {"fuel": [0, 50, 120, 200], "power": [0, 15, 50, 100], "heat": [0, 25, 45, 55]}, - dim="var", - ) - m.add_piecewise_constraints( - exprs={"fuel": fuel, "power": power, "heat": heat}, - breakpoints=bp, - ) - -At any feasible point, fuel, power, and heat are interpolated between the -*same* pair of adjacent breakpoints. - - -2-variable case: equality -~~~~~~~~~~~~~~~~~~~~~~~~~ - -The 2-variable equality constraint :math:`y = f(x)` is the most common use -case. Mathematically, it is equivalent to the N-variable form with two -expressions: - -.. math:: - - x = \sum_i \lambda_i \, x_i, \qquad - y = \sum_i \lambda_i \, y_i, \qquad - \sum_i \lambda_i = 1 - -Internally, the 2-variable equality form builds a dict and delegates to the -same N-variable code path. - -.. code-block:: python - - # These two are equivalent: - m.add_piecewise_constraints(x=x, y=y, x_points=xp, y_points=yp) - - m.add_piecewise_constraints( - exprs={"x": x, "y": y}, - breakpoints=linopy.breakpoints({"x": xp, "y": yp}, dim="var"), - ) - Tangent lines (inequality) ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -199,13 +148,6 @@ where :math:`m_k = (y_{k+1} - y_k) / (x_{k+1} - x_k)` is the slope and :class:`~linopy.expressions.LinearExpression` with a segment dimension --- one linear expression per segment, no auxiliary variables. -The user then adds their own constraint: - -.. code-block:: python - - t = linopy.tangent_lines(power, x_pts, y_pts) - m.add_constraints(fuel <= t) # one constraint per segment per timestep - Formulation Methods ------------------- @@ -237,8 +179,7 @@ incremental formulation uses fill-fraction variables: Binary indicators enforce segment ordering. This avoids SOS2 constraints entirely, using only standard MIP constructs. -**Limitation:** Breakpoints must be strictly monotonic along the breakpoint -dimension. +**Limitation:** All breakpoint sequences must be strictly monotonic. Disjunctive (Disaggregated Convex Combination) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -260,40 +201,11 @@ Choosing a Formulation Pass ``method="auto"`` (the default) and linopy picks the best formulation: -- **Equality + monotonic breakpoints** -> incremental +- **All breakpoints monotonic** -> incremental - Otherwise -> SOS2 - Disjunctive (segments) -> always SOS2 with binary selection - **Inequality** -> use ``tangent_lines`` + regular constraints -.. list-table:: - :header-rows: 1 - :widths: 25 20 20 20 - - * - Property - - SOS2 - - Incremental - - Disjunctive - * - Segments - - Connected - - Connected - - Disconnected - * - Constraint type - - Equality - - Equality - - Equality - * - Breakpoint order - - Any - - Strictly monotonic - - Any (per segment) - * - Variable types - - Continuous + SOS2 - - Continuous + binary - - Binary + SOS2 - * - N-variable support - - Yes - - Yes - - 2-var only - Usage Examples -------------- @@ -304,52 +216,38 @@ Usage Examples .. code-block:: python m.add_piecewise_constraints( - x=power, - y=fuel, - x_points=linopy.breakpoints([0, 30, 60, 100]), - y_points=linopy.breakpoints([0, 36, 84, 170]), + (power, linopy.breakpoints([0, 30, 60, 100])), + (fuel, linopy.breakpoints([0, 36, 84, 170])), ) -Inequality via envelope -~~~~~~~~~~~~~~~~~~~~~~~ - -.. code-block:: python - - # fuel <= f(power): y bounded above (concave function) - envelope = linopy.tangent_lines(power, x_pts, y_pts) - m.add_constraints(fuel <= envelope) - - # fuel >= f(power): y bounded below (convex function) - envelope = linopy.tangent_lines(power, x_pts, y_pts) - m.add_constraints(fuel >= envelope) - N-variable linking ~~~~~~~~~~~~~~~~~~ .. code-block:: python - bp = linopy.breakpoints( - {"power": [0, 30, 60, 100], "fuel": [0, 40, 85, 160], "heat": [0, 25, 55, 95]}, - dim="var", - ) m.add_piecewise_constraints( - exprs={"power": power, "fuel": fuel, "heat": heat}, - breakpoints=bp, + (power, [0, 30, 60, 100]), + (fuel, [0, 40, 85, 160]), + (heat, [0, 25, 55, 95]), ) +Inequality via tangent lines +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + t = linopy.tangent_lines(power, x_pts, y_pts) + m.add_constraints(fuel <= t) # upper bound (concave function) + m.add_constraints(fuel >= t) # lower bound (convex function) + Disjunctive (disconnected segments) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python - x_seg = linopy.segments([(0, 10), (50, 100)]) - y_seg = linopy.segments([(0, 15), (60, 130)]) - m.add_piecewise_constraints( - x=x, - y=y, - x_points=x_seg, - y_points=y_seg, + (x, linopy.segments([(0, 10), (50, 100)])), + (y, linopy.segments([(0, 15), (60, 130)])), ) Choosing a method @@ -357,13 +255,9 @@ Choosing a method .. code-block:: python - m.add_piecewise_constraints(x=x, y=y, x_points=xp, y_points=yp, method="sos2") - m.add_piecewise_constraints( - x=x, y=y, x_points=xp, y_points=yp, method="incremental" - ) - m.add_piecewise_constraints( - x=x, y=y, x_points=xp, y_points=yp, method="auto" - ) # default + m.add_piecewise_constraints((x, xp), (y, yp), method="sos2") + m.add_piecewise_constraints((x, xp), (y, yp), method="incremental") + m.add_piecewise_constraints((x, xp), (y, yp), method="auto") # default Active parameter (unit commitment) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -375,10 +269,8 @@ When ``active=0``, all auxiliary variables are forced to zero. commit = m.add_variables(name="commit", binary=True, coords=[time]) m.add_piecewise_constraints( - x=power, - y=fuel, - x_points=x_pts, - y_points=y_pts, + (power, x_pts), + (fuel, y_pts), active=commit, ) @@ -418,7 +310,7 @@ You don't need ``expand_dims`` when your variables have extra dimensions: y = m.add_variables(name="y", coords=[time]) # 1D breakpoints auto-expand to match x's time dimension - m.add_piecewise_constraints(x=x, y=y, x_points=[0, 50, 100], y_points=[0, 70, 150]) + m.add_piecewise_constraints((x, [0, 50, 100]), (y, [0, 70, 150])) Generated Variables and Constraints diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 093bde5f..ff32c4b3 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -3,2607 +3,473 @@ { "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 | Tangent lines |\n| 5 | Gas unit with commitment | On/off + min load | Incremental + `active` |\n| 6 | CHP plant (N-variable) | Joint power/fuel/heat | N-variable SOS2 |\n\n**API:** Each `(expression, breakpoints)` tuple links a variable to its breakpoints.\nAll tuples share interpolation weights, coupling them on the same curve segment.\n\n```python\nm.add_piecewise_constraints((power, x_pts), (fuel, y_pts))\n```" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.292583Z", + "start_time": "2026-04-01T07:35:36.286274Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.167007Z", + "iopub.status.busy": "2026-03-06T11:51:29.166576Z", + "iopub.status.idle": "2026-03-06T11:51:29.185103Z", + "shell.execute_reply": "2026-03-06T11:51:29.184712Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.166974Z" + } + }, + "outputs": [], "source": [ - "#", - " ", - "P", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - " ", - "L", - "i", - "n", - "e", - "a", - "r", - " ", - "C", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - " ", - "T", - "u", - "t", - "o", - "r", - "i", - "a", - "l", - "\n", + "import matplotlib.pyplot as plt\n", + "import pandas as pd\n", + "import xarray as xr\n", "\n", - "T", - "h", - "i", - "s", - " ", - "n", - "o", - "t", - "e", - "b", - "o", - "o", - "k", - " ", - "d", - "e", - "m", - "o", - "n", - "s", - "t", - "r", - "a", - "t", - "e", - "s", - " ", - "l", - "i", - "n", - "o", - "p", - "y", - "'", - "s", - " ", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - " ", - "l", - "i", - "n", - "e", - "a", - "r", - " ", - "(", - "P", - "W", - "L", - ")", - " ", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - " ", - "f", - "o", - "r", - "m", - "u", - "l", - "a", - "t", - "i", - "o", - "n", - "s", - ".", + "import linopy\n", "\n", - "E", - "a", - "c", - "h", - " ", - "e", - "x", - "a", - "m", - "p", - "l", - "e", - " ", - "b", - "u", - "i", - "l", - "d", - "s", - " ", - "a", - " ", - "s", - "e", - "p", - "a", - "r", - "a", - "t", - "e", - " ", - "d", - "i", - "s", - "p", - "a", - "t", - "c", - "h", - " ", - "m", - "o", - "d", - "e", - "l", - " ", - "w", - "h", - "e", - "r", - "e", - " ", - "a", - " ", - "s", - "i", - "n", - "g", - "l", - "e", - " ", - "p", - "o", - "w", - "e", - "r", - " ", - "p", - "l", - "a", - "n", - "t", - " ", - "m", - "u", - "s", - "t", - " ", - "m", - "e", - "e", - "t", + "time = pd.Index([1, 2, 3], name=\"time\")\n", "\n", - "a", - " ", - "t", - "i", - "m", - "e", - "-", - "v", - "a", - "r", - "y", - "i", - "n", - "g", - " ", - "d", - "e", - "m", - "a", - "n", - "d", - ".", "\n", + "def plot_pwl_results(model, breakpoints, demand, *, x_name=\"power\", color=\"C0\"):\n", + " \"\"\"\n", + " Plot PWL curves with operating points and dispatch vs demand.\n", "\n", - "|", - " ", - "E", - "x", - "a", - "m", - "p", - "l", - "e", - " ", - "|", - " ", - "P", - "l", - "a", - "n", - "t", - " ", - "|", - " ", - "L", - "i", - "m", - "i", - "t", - "a", - "t", - "i", - "o", - "n", - " ", - "|", - " ", - "F", - "o", - "r", - "m", - "u", - "l", - "a", - "t", - "i", - "o", - "n", - " ", - "|", - "\n", - "|", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "|", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "|", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "|", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "|", + " Parameters\n", + " ----------\n", + " model : linopy.Model\n", + " Solved model.\n", + " breakpoints : DataArray\n", + " Breakpoints array. For 2-variable cases pass a DataArray with a\n", + " \"var\" dimension containing two coordinates (x and y variable names).\n", + " Alternatively pass two separate arrays and they will be stacked.\n", + " demand : DataArray\n", + " Demand time series (plotted as step line).\n", + " x_name : str\n", + " Name of the x-axis variable (used for the curve plot).\n", + " color : str\n", + " Base color for the plot.\n", + " \"\"\"\n", + " sol = model.solution\n", + " var_names = list(breakpoints.coords[\"var\"].values)\n", + " bp_x = breakpoints.sel(var=x_name).values\n", "\n", - "|", - " ", - "1", - " ", - "|", - " ", - "G", - "a", - "s", - " ", - "t", - "u", - "r", - "b", - "i", - "n", - "e", - " ", - "(", - "0", - "-", - "1", - "0", - "0", - " ", - "M", - "W", - ")", - " ", - "|", - " ", - "C", - "o", - "n", - "v", - "e", - "x", - " ", - "h", - "e", - "a", - "t", - " ", - "r", - "a", - "t", - "e", - " ", - "|", - " ", - "S", - "O", - "S", - "2", - " ", - "|", + " fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3.5))\n", "\n", - "|", - " ", - "2", - " ", - "|", - " ", - "C", - "o", - "a", - "l", - " ", - "p", - "l", - "a", - "n", - "t", - " ", - "(", - "0", - "-", - "1", - "5", - "0", - " ", - "M", - "W", - ")", - " ", - "|", - " ", - "M", - "o", - "n", - "o", - "t", - "o", - "n", - "i", - "c", - " ", - "h", - "e", - "a", - "t", - " ", - "r", - "a", - "t", - "e", - " ", - "|", - " ", - "I", - "n", - "c", - "r", - "e", - "m", - "e", - "n", - "t", - "a", - "l", - " ", - "|", + " # Left: breakpoint curves with operating points\n", + " colors = [f\"C{i}\" for i in range(len(var_names))]\n", + " for var, c in zip(var_names, colors):\n", + " if var == x_name:\n", + " continue\n", + " bp_y = breakpoints.sel(var=var).values\n", + " ax1.plot(bp_x, bp_y, \"o-\", color=c, label=f\"{var} (breakpoints)\")\n", + " for t in time:\n", + " ax1.plot(\n", + " float(sol[x_name].sel(time=t)),\n", + " float(sol[var].sel(time=t)),\n", + " \"D\",\n", + " color=c,\n", + " ms=10,\n", + " )\n", + " ax1.set(xlabel=x_name.title(), title=\"PWL curve\")\n", + " ax1.legend()\n", "\n", - "|", - " ", - "3", - " ", - "|", - " ", - "D", - "i", - "e", - "s", - "e", - "l", - " ", - "g", - "e", - "n", - "e", - "r", - "a", - "t", - "o", - "r", - " ", - "(", - "o", - "f", - "f", - " ", - "o", - "r", - " ", - "5", - "0", - "-", - "8", - "0", - " ", - "M", - "W", - ")", - " ", - "|", - " ", - "F", - "o", - "r", - "b", - "i", - "d", - "d", - "e", - "n", - " ", - "z", - "o", - "n", - "e", - " ", - "|", - " ", - "D", - "i", - "s", - "j", - "u", - "n", - "c", - "t", - "i", - "v", - "e", - " ", - "|", + " # Right: dispatch vs demand\n", + " x = list(range(len(time)))\n", + " power_vals = sol[x_name].values\n", + " ax2.bar(x, power_vals, color=color, label=x_name.title())\n", + " if \"backup\" in sol:\n", + " ax2.bar(\n", + " x,\n", + " sol[\"backup\"].values,\n", + " bottom=power_vals,\n", + " color=\"C3\",\n", + " alpha=0.5,\n", + " label=\"Backup\",\n", + " )\n", + " ax2.step(\n", + " [v - 0.5 for v in x] + [x[-1] + 0.5],\n", + " list(demand.values) + [demand.values[-1]],\n", + " where=\"post\",\n", + " color=\"black\",\n", + " lw=2,\n", + " label=\"Demand\",\n", + " )\n", + " ax2.set(\n", + " xlabel=\"Time\",\n", + " ylabel=\"MW\",\n", + " title=\"Dispatch\",\n", + " xticks=x,\n", + " xticklabels=time.values,\n", + " )\n", + " ax2.legend()\n", + " plt.tight_layout()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. SOS2 formulation — Gas turbine\n", "\n", - "|", - " ", - "4", - " ", - "|", - " ", - "C", - "o", - "n", - "c", - "a", - "v", - "e", - " ", - "e", - "f", - "f", - "i", - "c", - "i", - "e", - "n", - "c", - "y", - " ", - "c", - "u", - "r", - "v", - "e", - " ", - "|", - " ", - "I", - "n", - "e", - "q", - "u", - "a", - "l", - "i", - "t", - "y", - " ", - "b", - "o", - "u", - "n", - "d", - " ", - "|", - " ", - "E", - "n", - "v", - "e", - "l", - "o", - "p", - "e", - " ", - "|", + "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", + "to link power output and fuel consumption via separate x/y breakpoints." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.312257Z", + "start_time": "2026-04-01T07:35:36.308964Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.185693Z", + "iopub.status.busy": "2026-03-06T11:51:29.185601Z", + "iopub.status.idle": "2026-03-06T11:51:29.199760Z", + "shell.execute_reply": "2026-03-06T11:51:29.199416Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.185683Z" + } + }, + "outputs": [], + "source": [ + "x_pts1 = linopy.breakpoints([0, 30, 60, 100])\n", + "y_pts1 = linopy.breakpoints([0, 36, 84, 170])\n", + "print(\"x_pts:\", x_pts1.values)\n", + "print(\"y_pts:\", y_pts1.values)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.365214Z", + "start_time": "2026-04-01T07:35:36.322511Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.200170Z", + "iopub.status.busy": "2026-03-06T11:51:29.200087Z", + "iopub.status.idle": "2026-03-06T11:51:29.266847Z", + "shell.execute_reply": "2026-03-06T11:51:29.266379Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.200161Z" + } + }, + "outputs": [], + "source": "m1 = linopy.Model()\n\npower = m1.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\nfuel = m1.add_variables(name=\"fuel\", lower=0, coords=[time])\n\n# breakpoints are auto-broadcast to match the time dimension\nm1.add_piecewise_constraints(\n (power, x_pts1),\n (fuel, y_pts1),\n name=\"pwl\",\n method=\"sos2\",\n)\n\ndemand1 = xr.DataArray([50, 80, 30], coords=[time])\nm1.add_constraints(power >= demand1, name=\"demand\")\nm1.add_objective(fuel.sum())" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.410875Z", + "start_time": "2026-04-01T07:35:36.367557Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.267522Z", + "iopub.status.busy": "2026-03-06T11:51:29.267433Z", + "iopub.status.idle": "2026-03-06T11:51:29.326758Z", + "shell.execute_reply": "2026-03-06T11:51:29.326518Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.267514Z" + } + }, + "outputs": [], + "source": [ + "m1.solve(reformulate_sos=\"auto\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.424283Z", + "start_time": "2026-04-01T07:35:36.419372Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.327139Z", + "iopub.status.busy": "2026-03-06T11:51:29.327044Z", + "iopub.status.idle": "2026-03-06T11:51:29.339334Z", + "shell.execute_reply": "2026-03-06T11:51:29.338974Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.327130Z" + } + }, + "outputs": [], + "source": [ + "m1.solution[[\"power\", \"fuel\"]].to_pandas()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.525484Z", + "start_time": "2026-04-01T07:35:36.436334Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.339689Z", + "iopub.status.busy": "2026-03-06T11:51:29.339608Z", + "iopub.status.idle": "2026-03-06T11:51:29.489677Z", + "shell.execute_reply": "2026-03-06T11:51:29.489280Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.339680Z" + } + }, + "outputs": [], + "source": [ + "bp1 = xr.concat([x_pts1, y_pts1], dim=pd.Index([\"power\", \"fuel\"], name=\"var\"))\n", + "plot_pwl_results(m1, bp1, demand1, color=\"C0\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Incremental formulation — Coal plant\n", "\n", - "|", - " ", - "5", - " ", - "|", - " ", - "G", - "a", - "s", - " ", - "u", - "n", - "i", - "t", - " ", - "w", - "i", - "t", - "h", - " ", - "c", - "o", - "m", - "m", - "i", - "t", - "m", - "e", - "n", - "t", - " ", - "|", - " ", - "O", - "n", - "/", - "o", - "f", - "f", - " ", - "+", - " ", - "m", - "i", - "n", - " ", - "l", - "o", - "a", - "d", - " ", - "|", - " ", - "I", - "n", - "c", - "r", - "e", - "m", - "e", - "n", - "t", - "a", - "l", - " ", - "+", - " ", - "`", - "a", - "c", - "t", - "i", - "v", - "e", - "`", - " ", - "|", - "\n", - "|", - " ", - "6", - " ", - "|", - " ", - "C", - "H", - "P", - " ", - "p", - "l", - "a", - "n", - "t", - " ", - "(", - "N", - "-", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - ")", - " ", - "|", - " ", - "J", - "o", - "i", - "n", - "t", - " ", - "p", - "o", - "w", - "e", - "r", - "/", - "f", - "u", - "e", - "l", - "/", - "h", - "e", - "a", - "t", - " ", - "|", - " ", - "N", - "-", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - " ", - "S", - "O", - "S", - "2", - " ", - "|", - "\n", - "\n", - "*", - "*", - "A", - "P", - "I", - ":", - "*", - "*", - "\n", - "-", - " ", - "*", - "*", - "2", - "-", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - ":", - "*", - "*", - " ", - "`", - "m", - ".", - "a", - "d", - "d", - "_", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - "_", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - "(", - "x", - "=", - "p", - "o", - "w", - "e", - "r", - ",", - " ", - "y", - "=", - "f", - "u", - "e", - "l", - ",", - " ", - "x", - "_", - "p", - "o", - "i", - "n", - "t", - "s", - "=", - "x", - "p", - ",", - " ", - "y", - "_", - "p", - "o", - "i", - "n", - "t", - "s", - "=", - "y", - "p", - ")", - "`", - "\n", - "-", - " ", - "*", - "*", - "N", - "-", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - ":", - "*", - "*", - " ", - "`", - "m", - ".", - "a", - "d", - "d", - "_", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - "_", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - "(", - "e", - "x", - "p", - "r", - "s", - "=", - "{", - ".", - ".", - ".", - "}", - ",", - " ", - "b", - "r", - "e", - "a", - "k", - "p", - "o", - "i", - "n", - "t", - "s", - "=", - "b", - "p", - ")", - "`", - "\n", - "-", - " ", - "*", - "*", - "E", - "n", - "v", - "e", - "l", - "o", - "p", - "e", - " ", - "(", - "i", - "n", - "e", - "q", - "u", - "a", - "l", - "i", - "t", - "y", - ")", - ":", - "*", - "*", - " ", - "`", - "l", - "i", - "n", - "o", - "p", - "y", - ".", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - "_", - "e", - "n", - "v", - "e", - "l", - "o", - "p", - "e", - "(", - "x", - ",", - " ", - "x", - "_", - "p", - "t", - "s", - ",", - " ", - "y", - "_", - "p", - "t", - "s", - ")", - "`", - " ", - "+", - " ", - "r", - "e", - "g", - "u", - "l", - "a", - "r", - " ", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.292583Z", - "start_time": "2026-04-01T07:35:36.286274Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.167007Z", - "iopub.status.busy": "2026-03-06T11:51:29.166576Z", - "iopub.status.idle": "2026-03-06T11:51:29.185103Z", - "shell.execute_reply": "2026-03-06T11:51:29.184712Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.166974Z" - } - }, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "import pandas as pd\n", - "import xarray as xr\n", - "\n", - "import linopy\n", - "\n", - "time = pd.Index([1, 2, 3], name=\"time\")\n", - "\n", - "\n", - "def plot_pwl_results(model, breakpoints, demand, *, x_name=\"power\", color=\"C0\"):\n", - " \"\"\"\n", - " Plot PWL curves with operating points and dispatch vs demand.\n", - "\n", - " Parameters\n", - " ----------\n", - " model : linopy.Model\n", - " Solved model.\n", - " breakpoints : DataArray\n", - " Breakpoints array. For 2-variable cases pass a DataArray with a\n", - " \"var\" dimension containing two coordinates (x and y variable names).\n", - " Alternatively pass two separate arrays and they will be stacked.\n", - " demand : DataArray\n", - " Demand time series (plotted as step line).\n", - " x_name : str\n", - " Name of the x-axis variable (used for the curve plot).\n", - " color : str\n", - " Base color for the plot.\n", - " \"\"\"\n", - " sol = model.solution\n", - " var_names = list(breakpoints.coords[\"var\"].values)\n", - " bp_x = breakpoints.sel(var=x_name).values\n", - "\n", - " fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3.5))\n", - "\n", - " # Left: breakpoint curves with operating points\n", - " colors = [f\"C{i}\" for i in range(len(var_names))]\n", - " for var, c in zip(var_names, colors):\n", - " if var == x_name:\n", - " continue\n", - " bp_y = breakpoints.sel(var=var).values\n", - " ax1.plot(bp_x, bp_y, \"o-\", color=c, label=f\"{var} (breakpoints)\")\n", - " for t in time:\n", - " ax1.plot(\n", - " float(sol[x_name].sel(time=t)),\n", - " float(sol[var].sel(time=t)),\n", - " \"D\",\n", - " color=c,\n", - " ms=10,\n", - " )\n", - " ax1.set(xlabel=x_name.title(), title=\"PWL curve\")\n", - " ax1.legend()\n", - "\n", - " # Right: dispatch vs demand\n", - " x = list(range(len(time)))\n", - " power_vals = sol[x_name].values\n", - " ax2.bar(x, power_vals, color=color, label=x_name.title())\n", - " if \"backup\" in sol:\n", - " ax2.bar(\n", - " x,\n", - " sol[\"backup\"].values,\n", - " bottom=power_vals,\n", - " color=\"C3\",\n", - " alpha=0.5,\n", - " label=\"Backup\",\n", - " )\n", - " ax2.step(\n", - " [v - 0.5 for v in x] + [x[-1] + 0.5],\n", - " list(demand.values) + [demand.values[-1]],\n", - " where=\"post\",\n", - " color=\"black\",\n", - " lw=2,\n", - " label=\"Demand\",\n", - " )\n", - " ax2.set(\n", - " xlabel=\"Time\",\n", - " ylabel=\"MW\",\n", - " title=\"Dispatch\",\n", - " xticks=x,\n", - " xticklabels=time.values,\n", - " )\n", - " ax2.legend()\n", - " plt.tight_layout()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 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", - "to link power output and fuel consumption via separate x/y breakpoints." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.312257Z", - "start_time": "2026-04-01T07:35:36.308964Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.185693Z", - "iopub.status.busy": "2026-03-06T11:51:29.185601Z", - "iopub.status.idle": "2026-03-06T11:51:29.199760Z", - "shell.execute_reply": "2026-03-06T11:51:29.199416Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.185683Z" - } - }, - "outputs": [], - "source": [ - "x_pts1 = linopy.breakpoints([0, 30, 60, 100])\n", - "y_pts1 = linopy.breakpoints([0, 36, 84, 170])\n", - "print(\"x_pts:\", x_pts1.values)\n", - "print(\"y_pts:\", y_pts1.values)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.365214Z", - "start_time": "2026-04-01T07:35:36.322511Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.200170Z", - "iopub.status.busy": "2026-03-06T11:51:29.200087Z", - "iopub.status.idle": "2026-03-06T11:51:29.266847Z", - "shell.execute_reply": "2026-03-06T11:51:29.266379Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.200161Z" - } - }, - "outputs": [], - "source": [ - "m1 = linopy.Model()\n", - "\n", - "power = m1.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", - "fuel = m1.add_variables(name=\"fuel\", lower=0, coords=[time])\n", - "\n", - "# breakpoints are auto-broadcast to match the time dimension\n", - "m1.add_piecewise_constraints(\n", - " x=power,\n", - " y=fuel,\n", - " x_points=x_pts1,\n", - " y_points=y_pts1,\n", - " name=\"pwl\",\n", - " method=\"sos2\",\n", - ")\n", - "\n", - "demand1 = xr.DataArray([50, 80, 30], coords=[time])\n", - "m1.add_constraints(power >= demand1, name=\"demand\")\n", - "m1.add_objective(fuel.sum())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.410875Z", - "start_time": "2026-04-01T07:35:36.367557Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.267522Z", - "iopub.status.busy": "2026-03-06T11:51:29.267433Z", - "iopub.status.idle": "2026-03-06T11:51:29.326758Z", - "shell.execute_reply": "2026-03-06T11:51:29.326518Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.267514Z" - } - }, - "outputs": [], - "source": [ - "m1.solve(reformulate_sos=\"auto\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.424283Z", - "start_time": "2026-04-01T07:35:36.419372Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.327139Z", - "iopub.status.busy": "2026-03-06T11:51:29.327044Z", - "iopub.status.idle": "2026-03-06T11:51:29.339334Z", - "shell.execute_reply": "2026-03-06T11:51:29.338974Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.327130Z" - } - }, - "outputs": [], - "source": [ - "m1.solution[[\"power\", \"fuel\"]].to_pandas()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.525484Z", - "start_time": "2026-04-01T07:35:36.436334Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.339689Z", - "iopub.status.busy": "2026-03-06T11:51:29.339608Z", - "iopub.status.idle": "2026-03-06T11:51:29.489677Z", - "shell.execute_reply": "2026-03-06T11:51:29.489280Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.339680Z" - } - }, - "outputs": [], - "source": [ - "bp1 = xr.concat([x_pts1, y_pts1], dim=pd.Index([\"power\", \"fuel\"], name=\"var\"))\n", - "plot_pwl_results(m1, bp1, demand1, color=\"C0\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 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 \u2014 which uses fill-fraction variables with binary indicators." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.531430Z", - "start_time": "2026-04-01T07:35:36.528406Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.490092Z", - "iopub.status.busy": "2026-03-06T11:51:29.490011Z", - "iopub.status.idle": "2026-03-06T11:51:29.500894Z", - "shell.execute_reply": "2026-03-06T11:51:29.500558Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.490084Z" - } - }, - "outputs": [], - "source": [ - "x_pts2 = linopy.breakpoints([0, 50, 100, 150])\n", - "y_pts2 = linopy.breakpoints([0, 55, 130, 225])\n", - "print(\"x_pts:\", x_pts2.values)\n", - "print(\"y_pts:\", y_pts2.values)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.605829Z", - "start_time": "2026-04-01T07:35:36.538213Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.501317Z", - "iopub.status.busy": "2026-03-06T11:51:29.501216Z", - "iopub.status.idle": "2026-03-06T11:51:29.604024Z", - "shell.execute_reply": "2026-03-06T11:51:29.603543Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.501307Z" - } - }, - "outputs": [], - "source": [ - "m2 = linopy.Model()\n", - "\n", - "power = m2.add_variables(name=\"power\", lower=0, upper=150, coords=[time])\n", - "fuel = m2.add_variables(name=\"fuel\", lower=0, coords=[time])\n", - "\n", - "m2.add_piecewise_constraints(\n", - " x=power,\n", - " y=fuel,\n", - " x_points=x_pts2,\n", - " y_points=y_pts2,\n", - " name=\"pwl\",\n", - " method=\"incremental\",\n", - ")\n", - "\n", - "demand2 = xr.DataArray([80, 120, 50], coords=[time])\n", - "m2.add_constraints(power >= demand2, name=\"demand\")\n", - "m2.add_objective(fuel.sum())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.661877Z", - "start_time": "2026-04-01T07:35:36.609352Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.604434Z", - "iopub.status.busy": "2026-03-06T11:51:29.604359Z", - "iopub.status.idle": "2026-03-06T11:51:29.680947Z", - "shell.execute_reply": "2026-03-06T11:51:29.680667Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.604427Z" - } - }, - "outputs": [], - "source": [ - "m2.solve(reformulate_sos=\"auto\");" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.674590Z", - "start_time": "2026-04-01T07:35:36.669960Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.681833Z", - "iopub.status.busy": "2026-03-06T11:51:29.681725Z", - "iopub.status.idle": "2026-03-06T11:51:29.698558Z", - "shell.execute_reply": "2026-03-06T11:51:29.698011Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.681822Z" - } - }, - "outputs": [], - "source": [ - "m2.solution[[\"power\", \"fuel\"]].to_pandas()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.766218Z", - "start_time": "2026-04-01T07:35:36.687140Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.699350Z", - "iopub.status.busy": "2026-03-06T11:51:29.699116Z", - "iopub.status.idle": "2026-03-06T11:51:29.852000Z", - "shell.execute_reply": "2026-03-06T11:51:29.851741Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.699334Z" - } - }, - "outputs": [], - "source": [ - "bp2 = xr.concat([x_pts2, y_pts2], dim=pd.Index([\"power\", \"fuel\"], name=\"var\"))\n", - "plot_pwl_results(m2, bp2, demand2, color=\"C1\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 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\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", - "\n", - "The disjunctive formulation is selected automatically when the breakpoint\n", - "arrays have a segment dimension (created by `linopy.segments()`)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.773687Z", - "start_time": "2026-04-01T07:35:36.769193Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.852397Z", - "iopub.status.busy": "2026-03-06T11:51:29.852305Z", - "iopub.status.idle": "2026-03-06T11:51:29.866500Z", - "shell.execute_reply": "2026-03-06T11:51:29.866141Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.852387Z" - } - }, - "outputs": [], - "source": [ - "# x-breakpoints define where each segment lives on the power axis\n", - "# y-breakpoints define the corresponding cost values\n", - "x_seg = linopy.segments([(0, 0), (50, 80)])\n", - "y_seg = linopy.segments([(0, 0), (125, 200)])\n", - "print(\"x segments:\\n\", x_seg.to_pandas())\n", - "print(\"y segments:\\n\", y_seg.to_pandas())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.862477Z", - "start_time": "2026-04-01T07:35:36.784561Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.866940Z", - "iopub.status.busy": "2026-03-06T11:51:29.866839Z", - "iopub.status.idle": "2026-03-06T11:51:29.955272Z", - "shell.execute_reply": "2026-03-06T11:51:29.954810Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.866931Z" - } - }, - "outputs": [], - "source": [ - "m3 = linopy.Model()\n", - "\n", - "power = m3.add_variables(name=\"power\", lower=0, upper=80, coords=[time])\n", - "cost = m3.add_variables(name=\"cost\", lower=0, coords=[time])\n", - "backup = m3.add_variables(name=\"backup\", lower=0, coords=[time])\n", - "\n", - "m3.add_piecewise_constraints(\n", - " x=power,\n", - " y=cost,\n", - " x_points=x_seg,\n", - " y_points=y_seg,\n", - " name=\"pwl\",\n", - ")\n", - "\n", - "demand3 = xr.DataArray([10, 70, 90], coords=[time])\n", - "m3.add_constraints(power + backup >= demand3, name=\"demand\")\n", - "m3.add_objective((cost + 10 * backup).sum())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.925139Z", - "start_time": "2026-04-01T07:35:36.865201Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.955750Z", - "iopub.status.busy": "2026-03-06T11:51:29.955667Z", - "iopub.status.idle": "2026-03-06T11:51:30.027311Z", - "shell.execute_reply": "2026-03-06T11:51:30.026945Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.955741Z" - } - }, - "outputs": [], - "source": [ - "m3.solve(reformulate_sos=\"auto\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.935504Z", - "start_time": "2026-04-01T07:35:36.928757Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:30.028114Z", - "iopub.status.busy": "2026-03-06T11:51:30.027864Z", - "iopub.status.idle": "2026-03-06T11:51:30.043138Z", - "shell.execute_reply": "2026-03-06T11:51:30.042813Z", - "shell.execute_reply.started": "2026-03-06T11:51:30.028095Z" - } - }, - "outputs": [], - "source": [ - "m3.solution[[\"power\", \"cost\", \"backup\"]].to_pandas()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#", - "#", - " ", - "4", - ".", - " ", - "E", - "n", - "v", - "e", - "l", - "o", - "p", - "e", - " ", - "f", - "o", - "r", - "m", - "u", - "l", - "a", - "t", - "i", - "o", - "n", - " ", - "\u2014", - " ", - "C", - "o", - "n", - "c", - "a", - "v", - "e", - " ", - "e", - "f", - "f", - "i", - "c", - "i", - "e", - "n", - "c", - "y", - " ", - "b", - "o", - "u", - "n", - "d", - "\n", - "\n", - "W", - "h", - "e", - "n", - " ", - "t", - "h", - "e", - " ", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - " ", - "f", - "u", - "n", - "c", - "t", - "i", - "o", - "n", - " ", - "i", - "s", - " ", - "*", - "*", - "c", - "o", - "n", - "c", - "a", - "v", - "e", - "*", - "*", - " ", - "a", - "n", - "d", - " ", - "w", - "e", - " ", - "w", - "a", - "n", - "t", - " ", - "t", - "o", - " ", - "b", - "o", - "u", - "n", - "d", - " ", - "y", - " ", - "*", - "*", - "a", - "b", - "o", - "v", - "e", - "*", - "*", - "\n", - "(", - "i", - ".", - "e", - ".", - " ", - "`", - "y", - " ", - "<", - "=", - " ", - "f", - "(", - "x", - ")", - "`", - ")", - ",", - " ", - "w", - "e", - " ", - "c", - "a", - "n", - " ", - "u", - "s", - "e", - " ", - "`", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - "_", - "e", - "n", - "v", - "e", - "l", - "o", - "p", - "e", - "`", - " ", - "t", - "o", - " ", - "g", - "e", - "t", - " ", - "t", - "a", - "n", - "g", - "e", - "n", - "t", - "-", - "l", - "i", - "n", - "e", - "\n", - "e", - "x", - "p", - "r", - "e", - "s", - "s", - "i", - "o", - "n", - "s", - " ", - "a", - "n", - "d", - " ", - "a", - "d", - "d", - " ", - "t", - "h", - "e", - "m", - " ", - "a", - "s", - " ", - "r", - "e", - "g", - "u", - "l", - "a", - "r", - " ", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - " ", - "\u2014", - " ", - "n", - "o", - " ", - "S", - "O", - "S", - "2", - " ", - "o", - "r", - " ", - "b", - "i", - "n", - "a", - "r", - "y", - "\n", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - "s", - " ", - "n", - "e", - "e", - "d", - "e", - "d", - ".", - " ", - "T", - "h", - "i", - "s", - " ", - "i", - "s", - " ", - "t", - "h", - "e", - " ", - "f", - "a", - "s", - "t", - "e", - "s", - "t", - " ", - "t", - "o", - " ", - "s", - "o", - "l", - "v", - "e", - ".", - "\n", - "\n", - "H", - "e", - "r", - "e", - " ", - "w", - "e", - " ", - "b", - "o", - "u", - "n", - "d", - " ", - "f", - "u", - "e", - "l", - " ", - "c", - "o", - "n", - "s", - "u", - "m", - "p", - "t", - "i", - "o", - "n", - " ", - "*", - "b", - "e", - "l", - "o", - "w", - "*", - " ", - "a", - " ", - "c", - "o", - "n", - "c", - "a", - "v", - "e", - " ", - "e", - "f", - "f", - "i", - "c", - "i", - "e", - "n", - "c", - "y", - " ", - "e", - "n", - "v", - "e", - "l", - "o", - "p", - "e", - "." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.990196Z", - "start_time": "2026-04-01T07:35:36.947234Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:30.043492Z", - "iopub.status.busy": "2026-03-06T11:51:30.043410Z", - "iopub.status.idle": "2026-03-06T11:51:30.113382Z", - "shell.execute_reply": "2026-03-06T11:51:30.112320Z", - "shell.execute_reply.started": "2026-03-06T11:51:30.043484Z" - } - }, - "outputs": [], - "source": [ - "x", - "_", - "p", - "t", - "s", - "4", - " ", - "=", - " ", - "l", - "i", - "n", - "o", - "p", - "y", - ".", - "b", - "r", - "e", - "a", - "k", - "p", - "o", - "i", - "n", - "t", - "s", - "(", - "[", - "0", - ",", - " ", - "4", - "0", - ",", - " ", - "8", - "0", - ",", - " ", - "1", - "2", - "0", - "]", - ")", - "\n", - "#", - " ", - "C", - "o", - "n", - "c", - "a", - "v", - "e", - " ", - "c", - "u", - "r", - "v", - "e", - ":", - " ", - "d", - "e", - "c", - "r", - "e", - "a", - "s", - "i", - "n", - "g", - " ", - "m", - "a", - "r", - "g", - "i", - "n", - "a", - "l", - " ", - "f", - "u", - "e", - "l", - " ", - "p", - "e", - "r", - " ", - "M", - "W", - "\n", - "y", - "_", - "p", - "t", - "s", - "4", - " ", - "=", - " ", - "l", - "i", - "n", - "o", - "p", - "y", - ".", - "b", - "r", - "e", - "a", - "k", - "p", - "o", - "i", - "n", - "t", - "s", - "(", - "[", - "0", - ",", - " ", - "5", - "0", - ",", - " ", - "9", - "0", - ",", - " ", - "1", - "2", - "0", - "]", - ")", - "\n", - "\n", - "m", - "4", - " ", - "=", - " ", - "l", - "i", - "n", - "o", - "p", - "y", - ".", - "M", - "o", - "d", - "e", - "l", - "(", - ")", - "\n", - "\n", - "p", - "o", - "w", - "e", - "r", - " ", - "=", - " ", - "m", - "4", - ".", - "a", - "d", - "d", - "_", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - "s", - "(", - "n", - "a", - "m", - "e", - "=", - "\"", - "p", - "o", - "w", - "e", - "r", - "\"", - ",", - " ", - "l", - "o", - "w", - "e", - "r", - "=", - "0", - ",", - " ", - "u", - "p", - "p", - "e", - "r", - "=", - "1", - "2", - "0", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "f", - "u", - "e", - "l", - " ", - "=", - " ", - "m", - "4", - ".", - "a", - "d", - "d", - "_", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - "s", - "(", - "n", - "a", - "m", - "e", - "=", - "\"", - "f", - "u", - "e", - "l", - "\"", - ",", - " ", - "l", - "o", - "w", - "e", - "r", - "=", - "0", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "\n", - "#", - " ", - "U", - "s", - "e", - " ", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - "_", - "e", - "n", - "v", - "e", - "l", - "o", - "p", - "e", - " ", - "t", - "o", - " ", - "g", - "e", - "t", - " ", - "t", - "a", - "n", - "g", - "e", - "n", - "t", - "-", - "l", - "i", - "n", - "e", - " ", - "e", - "x", - "p", - "r", - "e", - "s", - "s", - "i", - "o", - "n", - "s", - ",", - " ", - "t", - "h", - "e", - "n", - " ", - "a", - "d", - "d", - " ", - "a", - "s", - " ", - "<", - "=", - " ", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "\n", - "e", - "n", - "v", - "e", - "l", - "o", - "p", - "e", - " ", - "=", - " ", - "l", - "i", - "n", - "o", - "p", - "y", - ".", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - "_", - "e", - "n", - "v", - "e", - "l", - "o", - "p", - "e", - "(", - "p", - "o", - "w", - "e", - "r", - ",", - " ", - "x", - "_", - "p", - "t", - "s", - "4", - ",", - " ", - "y", - "_", - "p", - "t", - "s", - "4", - ")", - "\n", - "m", - "4", - ".", - "a", - "d", - "d", - "_", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - "(", - "f", - "u", - "e", - "l", - " ", - "<", - "=", - " ", - "e", - "n", - "v", - "e", - "l", - "o", - "p", - "e", - ",", - " ", - "n", - "a", - "m", - "e", - "=", - "\"", - "p", - "w", - "l", - "\"", - ")", - "\n", - "\n", - "d", - "e", - "m", - "a", - "n", - "d", - "4", - " ", - "=", - " ", - "x", - "r", - ".", - "D", - "a", - "t", - "a", - "A", - "r", - "r", - "a", - "y", - "(", - "[", - "3", - "0", - ",", - " ", - "8", - "0", - ",", - " ", - "1", - "0", - "0", - "]", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "m", - "4", - ".", - "a", - "d", - "d", - "_", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - "(", - "p", - "o", - "w", - "e", - "r", - " ", - "=", - "=", - " ", - "d", - "e", - "m", - "a", - "n", - "d", - "4", - ",", - " ", - "n", - "a", - "m", - "e", - "=", - "\"", - "d", - "e", - "m", - "a", - "n", - "d", - "\"", - ")", + "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." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.531430Z", + "start_time": "2026-04-01T07:35:36.528406Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.490092Z", + "iopub.status.busy": "2026-03-06T11:51:29.490011Z", + "iopub.status.idle": "2026-03-06T11:51:29.500894Z", + "shell.execute_reply": "2026-03-06T11:51:29.500558Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.490084Z" + } + }, + "outputs": [], + "source": [ + "x_pts2 = linopy.breakpoints([0, 50, 100, 150])\n", + "y_pts2 = linopy.breakpoints([0, 55, 130, 225])\n", + "print(\"x_pts:\", x_pts2.values)\n", + "print(\"y_pts:\", y_pts2.values)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.605829Z", + "start_time": "2026-04-01T07:35:36.538213Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.501317Z", + "iopub.status.busy": "2026-03-06T11:51:29.501216Z", + "iopub.status.idle": "2026-03-06T11:51:29.604024Z", + "shell.execute_reply": "2026-03-06T11:51:29.603543Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.501307Z" + } + }, + "outputs": [], + "source": "m2 = linopy.Model()\n\npower = m2.add_variables(name=\"power\", lower=0, upper=150, coords=[time])\nfuel = m2.add_variables(name=\"fuel\", lower=0, coords=[time])\n\nm2.add_piecewise_constraints(\n (power, x_pts2),\n (fuel, y_pts2),\n name=\"pwl\",\n method=\"incremental\",\n)\n\ndemand2 = xr.DataArray([80, 120, 50], coords=[time])\nm2.add_constraints(power >= demand2, name=\"demand\")\nm2.add_objective(fuel.sum())" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.661877Z", + "start_time": "2026-04-01T07:35:36.609352Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.604434Z", + "iopub.status.busy": "2026-03-06T11:51:29.604359Z", + "iopub.status.idle": "2026-03-06T11:51:29.680947Z", + "shell.execute_reply": "2026-03-06T11:51:29.680667Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.604427Z" + } + }, + "outputs": [], + "source": [ + "m2.solve(reformulate_sos=\"auto\");" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.674590Z", + "start_time": "2026-04-01T07:35:36.669960Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.681833Z", + "iopub.status.busy": "2026-03-06T11:51:29.681725Z", + "iopub.status.idle": "2026-03-06T11:51:29.698558Z", + "shell.execute_reply": "2026-03-06T11:51:29.698011Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.681822Z" + } + }, + "outputs": [], + "source": [ + "m2.solution[[\"power\", \"fuel\"]].to_pandas()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.766218Z", + "start_time": "2026-04-01T07:35:36.687140Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.699350Z", + "iopub.status.busy": "2026-03-06T11:51:29.699116Z", + "iopub.status.idle": "2026-03-06T11:51:29.852000Z", + "shell.execute_reply": "2026-03-06T11:51:29.851741Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.699334Z" + } + }, + "outputs": [], + "source": [ + "bp2 = xr.concat([x_pts2, y_pts2], dim=pd.Index([\"power\", \"fuel\"], name=\"var\"))\n", + "plot_pwl_results(m2, bp2, demand2, color=\"C1\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Disjunctive formulation — Diesel generator\n", "\n", - "#", - " ", - "M", - "a", - "x", - "i", - "m", - "i", - "z", - "e", - " ", - "f", - "u", - "e", - "l", - " ", - "(", - "t", - "o", - " ", - "p", - "u", - "s", - "h", - " ", - "a", - "g", - "a", - "i", - "n", - "s", - "t", - " ", - "t", - "h", - "e", - " ", - "u", - "p", - "p", - "e", - "r", - " ", - "b", - "o", - "u", - "n", - "d", - ")", + "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", + "**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", "\n", - "m", - "4", - ".", - "a", - "d", - "d", - "_", - "o", - "b", - "j", - "e", - "c", - "t", - "i", - "v", - "e", - "(", - "-", - "f", - "u", - "e", - "l", - ".", - "s", - "u", - "m", - "(", - ")", - ")" + "The disjunctive formulation is selected automatically when the breakpoint\n", + "arrays have a segment dimension (created by `linopy.segments()`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.773687Z", + "start_time": "2026-04-01T07:35:36.769193Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.852397Z", + "iopub.status.busy": "2026-03-06T11:51:29.852305Z", + "iopub.status.idle": "2026-03-06T11:51:29.866500Z", + "shell.execute_reply": "2026-03-06T11:51:29.866141Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.852387Z" + } + }, + "outputs": [], + "source": [ + "# x-breakpoints define where each segment lives on the power axis\n", + "# y-breakpoints define the corresponding cost values\n", + "x_seg = linopy.segments([(0, 0), (50, 80)])\n", + "y_seg = linopy.segments([(0, 0), (125, 200)])\n", + "print(\"x segments:\\n\", x_seg.to_pandas())\n", + "print(\"y segments:\\n\", y_seg.to_pandas())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.862477Z", + "start_time": "2026-04-01T07:35:36.784561Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.866940Z", + "iopub.status.busy": "2026-03-06T11:51:29.866839Z", + "iopub.status.idle": "2026-03-06T11:51:29.955272Z", + "shell.execute_reply": "2026-03-06T11:51:29.954810Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.866931Z" + } + }, + "outputs": [], + "source": "m3 = linopy.Model()\n\npower = m3.add_variables(name=\"power\", lower=0, upper=80, coords=[time])\ncost = m3.add_variables(name=\"cost\", lower=0, coords=[time])\nbackup = m3.add_variables(name=\"backup\", lower=0, coords=[time])\n\nm3.add_piecewise_constraints(\n (power, x_seg),\n (cost, y_seg),\n name=\"pwl\",\n)\n\ndemand3 = xr.DataArray([10, 70, 90], coords=[time])\nm3.add_constraints(power + backup >= demand3, name=\"demand\")\nm3.add_objective((cost + 10 * backup).sum())" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.925139Z", + "start_time": "2026-04-01T07:35:36.865201Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.955750Z", + "iopub.status.busy": "2026-03-06T11:51:29.955667Z", + "iopub.status.idle": "2026-03-06T11:51:30.027311Z", + "shell.execute_reply": "2026-03-06T11:51:30.026945Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.955741Z" + } + }, + "outputs": [], + "source": [ + "m3.solve(reformulate_sos=\"auto\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.935504Z", + "start_time": "2026-04-01T07:35:36.928757Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:30.028114Z", + "iopub.status.busy": "2026-03-06T11:51:30.027864Z", + "iopub.status.idle": "2026-03-06T11:51:30.043138Z", + "shell.execute_reply": "2026-03-06T11:51:30.042813Z", + "shell.execute_reply.started": "2026-03-06T11:51:30.028095Z" + } + }, + "outputs": [], + "source": [ + "m3.solution[[\"power\", \"cost\", \"backup\"]].to_pandas()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 4. Tangent lines — Concave efficiency bound\n\nWhen the piecewise function is **concave** and we want to bound y **above**\n(i.e. `y <= f(x)`), we can use `tangent_lines` to get per-segment linear\nexpressions and add them as regular constraints — no SOS2 or binary\nvariables needed. This is the fastest to solve.\n\nHere we bound fuel consumption *below* a concave efficiency curve." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T07:35:36.990196Z", + "start_time": "2026-04-01T07:35:36.947234Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:30.043492Z", + "iopub.status.busy": "2026-03-06T11:51:30.043410Z", + "iopub.status.idle": "2026-03-06T11:51:30.113382Z", + "shell.execute_reply": "2026-03-06T11:51:30.112320Z", + "shell.execute_reply.started": "2026-03-06T11:51:30.043484Z" + } + }, + "outputs": [], + "source": "x_pts4 = linopy.breakpoints([0, 40, 80, 120])\n# Concave curve: decreasing marginal fuel per MW\ny_pts4 = linopy.breakpoints([0, 50, 90, 120])\n\nm4 = linopy.Model()\n\npower = m4.add_variables(name=\"power\", lower=0, upper=120, coords=[time])\nfuel = m4.add_variables(name=\"fuel\", lower=0, coords=[time])\n\n# tangent_lines returns one LinearExpression per segment — pure LP, no aux variables\nt = linopy.tangent_lines(power, x_pts4, y_pts4)\nm4.add_constraints(fuel <= t, name=\"pwl\")\n\ndemand4 = xr.DataArray([30, 80, 100], coords=[time])\nm4.add_constraints(power == demand4, name=\"demand\")\n# Maximize fuel (to push against the upper bound)\nm4.add_objective(-fuel.sum())" + }, { "cell_type": "code", "execution_count": null, @@ -2672,7 +538,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 5. Slopes mode \u2014 Building breakpoints from slopes\n", + "## 5. Slopes mode — 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", @@ -3648,34 +1514,7 @@ } }, "outputs": [], - "source": [ - "m6 = linopy.Model()\n", - "\n", - "power = m6.add_variables(name=\"power\", lower=0, upper=p_max, coords=[time])\n", - "fuel = m6.add_variables(name=\"fuel\", lower=0, coords=[time])\n", - "commit = m6.add_variables(name=\"commit\", binary=True, coords=[time])\n", - "\n", - "# The active parameter gates the PWL with the commitment binary:\n", - "# - commit=1: power in [30, 100], fuel = f(power)\n", - "# - commit=0: power = 0, fuel = 0\n", - "m6.add_piecewise_constraints(\n", - " x=power,\n", - " y=fuel,\n", - " x_points=x_pts6,\n", - " y_points=y_pts6,\n", - " active=commit,\n", - " name=\"pwl\",\n", - " method=\"incremental\",\n", - ")\n", - "\n", - "# Demand: low at t=1 (cheaper to stay off), high at t=2,3\n", - "demand6 = xr.DataArray([15, 70, 50], coords=[time])\n", - "backup = m6.add_variables(name=\"backup\", lower=0, coords=[time])\n", - "m6.add_constraints(power + backup >= demand6, name=\"demand\")\n", - "\n", - "# Objective: fuel + startup cost + backup at $5/MW\n", - "m6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())" - ] + "source": "m6 = linopy.Model()\n\npower = m6.add_variables(name=\"power\", lower=0, upper=p_max, coords=[time])\nfuel = m6.add_variables(name=\"fuel\", lower=0, coords=[time])\ncommit = m6.add_variables(name=\"commit\", binary=True, coords=[time])\n\n# The active parameter gates the PWL with the commitment binary:\n# - commit=1: power in [30, 100], fuel = f(power)\n# - commit=0: power = 0, fuel = 0\nm6.add_piecewise_constraints(\n (power, x_pts6),\n (fuel, y_pts6),\n active=commit,\n name=\"pwl\",\n method=\"incremental\",\n)\n\n# Demand: low at t=1 (cheaper to stay off), high at t=2,3\ndemand6 = xr.DataArray([15, 70, 50], coords=[time])\nbackup = m6.add_variables(name=\"backup\", lower=0, coords=[time])\nm6.add_constraints(power + backup >= demand6, name=\"demand\")\n\n# Objective: fuel + startup cost + backup at $5/MW\nm6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())" }, { "cell_type": "code", @@ -3856,7 +1695,7 @@ "0", "`", " ", - "\u2014", + "—", " ", "t", "h", @@ -4452,27 +2291,7 @@ } }, "outputs": [], - "source": [ - "m7 = linopy.Model()\n", - "\n", - "power = m7.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", - "fuel = m7.add_variables(name=\"fuel\", lower=0, coords=[time])\n", - "heat = m7.add_variables(name=\"heat\", lower=0, coords=[time])\n", - "\n", - "# N-variable API: link power, fuel, and heat through shared breakpoints\n", - "m7.add_piecewise_constraints(\n", - " exprs={\"power\": power, \"fuel\": fuel, \"heat\": heat},\n", - " breakpoints=bp_chp,\n", - " name=\"chp\",\n", - " method=\"sos2\",\n", - ")\n", - "\n", - "# Fixed power dispatch determines the operating point \u2014 fuel and heat follow\n", - "power_dispatch = xr.DataArray([20, 60, 90], coords=[time])\n", - "m7.add_constraints(power == power_dispatch, name=\"power_dispatch\")\n", - "\n", - "m7.add_objective(fuel.sum())" - ] + "source": "m7 = linopy.Model()\n\npower = m7.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\nfuel = m7.add_variables(name=\"fuel\", lower=0, coords=[time])\nheat = m7.add_variables(name=\"heat\", lower=0, coords=[time])\n\n# N-variable: all three linked through shared interpolation weights\nm7.add_piecewise_constraints(\n (power, bp_chp.sel(var=\"power\")),\n (fuel, bp_chp.sel(var=\"fuel\")),\n (heat, bp_chp.sel(var=\"heat\")),\n name=\"chp\",\n method=\"sos2\",\n)\n\n# Fixed power dispatch determines the operating point — fuel and heat follow\npower_dispatch = xr.DataArray([20, 60, 90], coords=[time])\nm7.add_constraints(power == power_dispatch, name=\"power_dispatch\")\n\nm7.add_objective(fuel.sum())" }, { "cell_type": "code", diff --git a/linopy/piecewise.py b/linopy/piecewise.py index a48fa604..15efccf4 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -7,7 +7,7 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from numbers import Real from typing import TYPE_CHECKING, Literal, TypeAlias @@ -804,81 +804,50 @@ def _add_dpwl_sos2_core( def add_piecewise_constraints( model: Model, - *, - exprs: Mapping[str, LinExprLike] | None = None, - breakpoints: DataArray | None = None, - x: LinExprLike | None = None, - y: LinExprLike | None = None, - x_points: BreaksLike | None = None, - y_points: BreaksLike | None = None, - active: LinExprLike | None = None, - mask: DataArray | None = None, + *pairs: tuple[LinExprLike, BreaksLike], method: Literal["sos2", "incremental", "auto"] = "auto", + active: LinExprLike | None = None, name: str | None = None, skip_nan_check: bool = False, ) -> Constraint: r""" Add piecewise linear equality constraints. - Supports two calling conventions: - - **N-variable --- link N expressions through shared breakpoints:** + Each positional argument is a ``(expression, breakpoints)`` tuple. + All expressions are linked through shared interpolation weights so + that every operating point lies on the same segment of the piecewise + curve. - All expressions are symmetric and linked via shared SOS2 lambda - (or incremental delta) weights. Mathematically, each expression is - constrained to lie on the interpolated breakpoint curve:: + Example — 2 variables:: m.add_piecewise_constraints( - exprs={"power": power, "fuel": fuel, "heat": heat}, - breakpoints=bp, + (power, [0, 30, 60, 100]), + (fuel, [0, 36, 84, 170]), ) - **2-variable convenience --- link x and y via separate breakpoints:** - - A shorthand that builds the N-variable dict internally. The - constraint is:: - - y = f(x) - - where *f* is the piecewise linear function defined by the breakpoints. - This is mathematically equivalent to the N-variable form with two - expressions. - - For inequality constraints (y <= f(x) or y >= f(x)), use - :func:`~linopy.piecewise.tangent_lines` with regular - ``add_constraints`` instead. - - Example:: + Example — 3 variables (CHP plant):: m.add_piecewise_constraints( - x=power, y=fuel, x_points=x_pts, y_points=y_pts, + (power, [0, 30, 60, 100]), + (fuel, [0, 40, 85, 160]), + (heat, [0, 25, 55, 95]), ) + For inequality constraints (:math:`y \le f(x)` or + :math:`y \ge f(x)`), use :func:`tangent_lines` with regular + ``add_constraints`` instead. + Parameters ---------- - exprs : dict of str to Variable/LinearExpression - Expressions to link (N-variable case). Keys must match a - dimension of ``breakpoints``. - breakpoints : DataArray - Shared breakpoint array (N-variable case). Must have a - breakpoint dimension and a linking dimension whose coordinates - match the ``exprs`` keys. - x : Variable or LinearExpression - The input expression (2-variable case). - y : Variable or LinearExpression - The output expression (2-variable case). - x_points : BreaksLike - Breakpoint x-coordinates (2-variable case). - y_points : BreaksLike - Breakpoint y-coordinates (2-variable case). - active : Variable or LinearExpression, optional - Binary variable that gates the piecewise function. When - ``active=0``, all auxiliary variables (and thus *x* and *y*) - are forced to zero. 2-variable case only. - mask : DataArray, optional - Boolean mask for valid constraints. + *pairs : tuple of (expression, breakpoints) + Each pair links an expression (Variable or LinearExpression) + to its breakpoint values (list, DataArray, etc.). At least + two pairs are required. method : {"auto", "sos2", "incremental"}, default "auto" Formulation method. + active : Variable or LinearExpression, optional + Binary variable that gates the piecewise function. When + ``active=0``, all auxiliary variables are forced to zero. name : str, optional Base name for generated variables/constraints. skip_nan_check : bool, default False @@ -888,281 +857,128 @@ def add_piecewise_constraints( ------- Constraint """ - if exprs is not None: - # -- N-variable path -- - if breakpoints is None: - raise TypeError( - "N-variable call requires both 'exprs' and 'breakpoints' keywords." - ) - return _add_piecewise_nvar( - model, - exprs=dict(exprs), - breakpoints_da=breakpoints, - method=method, - name=name, - mask=mask, - skip_nan_check=skip_nan_check, - ) - - # -- 2-variable convenience path -- - if x is None or y is None or x_points is None or y_points is None: + if len(pairs) < 2: raise TypeError( - "add_piecewise_constraints() requires either:\n" - " - N-variable: exprs={...}, breakpoints=...\n" - " - 2-variable: x=..., y=..., x_points=..., y_points=..." - ) - return _add_piecewise_2var( - model, - x=x, - y=y, - x_points=x_points, - y_points=y_points, - method=method, - active=active, - name=name, - skip_nan_check=skip_nan_check, - ) - - -def _add_piecewise_2var( - model: Model, - x: LinExprLike, - y: LinExprLike, - x_points: BreaksLike, - y_points: BreaksLike, - method: str = "auto", - active: LinExprLike | None = None, - name: str | None = None, - skip_nan_check: bool = False, -) -> Constraint: - """2-variable piecewise equality constraint: y = f(x).""" - if method not in ("sos2", "incremental", "auto"): - raise ValueError( - f"method must be 'sos2', 'incremental', or 'auto', got '{method}'" + "add_piecewise_constraints() requires at least 2 " + "(expression, breakpoints) pairs." ) - # Coerce breakpoints - if not isinstance(x_points, DataArray): - x_points = _coerce_breaks(x_points) - if not isinstance(y_points, DataArray): - y_points = _coerce_breaks(y_points) - - disjunctive = _validate_xy_points(x_points, y_points) + for i, pair in enumerate(pairs): + if not isinstance(pair, tuple) or len(pair) != 2: + raise TypeError( + f"Argument {i + 1} must be a (expression, breakpoints) tuple, " + f"got {type(pair)}." + ) - # Broadcast points to match expression dimensions - x_points = _broadcast_points(x_points, x, y, disjunctive=disjunctive) - y_points = _broadcast_points(y_points, x, y, disjunctive=disjunctive) + # Coerce all breakpoints + coerced: list[tuple[LinExprLike, DataArray]] = [] + for expr, bp in pairs: + if not isinstance(bp, DataArray): + bp = _coerce_breaks(bp) + coerced.append((expr, bp)) + + # Check for disjunctive (segment dimension) on first pair + first_bp = coerced[0][1] + disjunctive = SEGMENT_DIM in first_bp.dims + + # Validate all breakpoint pairs have compatible shapes + for i in range(1, len(coerced)): + _validate_xy_points(first_bp, coerced[i][1]) + + # Broadcast all breakpoints to match all expression dimensions + all_exprs = [expr for expr, _ in coerced] + bp_list = [ + _broadcast_points(bp, *all_exprs, disjunctive=disjunctive) for _, bp in coerced + ] - # Compute mask - bp_mask = _compute_combined_mask(x_points, y_points, skip_nan_check) + # Compute combined mask from all breakpoints + if skip_nan_check: + for bp in bp_list: + if bool(bp.isnull().any()): + raise ValueError( + "skip_nan_check=True but breakpoints contain NaN. " + "Either remove NaN values or set skip_nan_check=False." + ) + bp_mask = None + else: + combined_null = bp_list[0].isnull() + for bp in bp_list[1:]: + combined_null = combined_null | bp.isnull() + bp_mask = ~combined_null if bool(combined_null.any()) else None # Name if name is None: name = f"pwl{model._pwlCounter}" model._pwlCounter += 1 - # Convert to LinearExpressions - x_expr = _to_linexpr(x) - y_expr = _to_linexpr(y) + # Convert expressions to LinearExpressions + lin_exprs = [_to_linexpr(expr) for expr in all_exprs] active_expr = _to_linexpr(active) if active is not None else None if disjunctive: + # Disjunctive only supports 2-variable for now + if len(coerced) != 2: + raise ValueError( + "Disjunctive piecewise constraints currently support " + "exactly 2 (expression, breakpoints) pairs." + ) return _add_disjunctive( model, name, - x_expr, - y_expr, - x_points, - y_points, - bp_mask, - method, - active_expr, - ) - else: - return _add_continuous( - model, - name, - x_expr, - y_expr, - x_points, - y_points, + lin_exprs[0], + lin_exprs[1], + bp_list[0], + bp_list[1], bp_mask, method, - skip_nan_check, active_expr, ) - -# --------------------------------------------------------------------------- -# N-variable path (shared-lambda linking) -# --------------------------------------------------------------------------- - - -def _resolve_link_dim( - bp: DataArray, - expr_keys: set[str], - exclude_dims: set[str], -) -> str: - """Auto-detect the linking dimension from breakpoints.""" - for d in bp.dims: - if d in exclude_dims: - continue - coord_set = {str(c) for c in bp.coords[d].values} - if coord_set == expr_keys: - return str(d) - raise ValueError( - "Could not auto-detect linking dimension from breakpoints. " - "Ensure breakpoints have a dimension whose coordinates match " - f"the expression dict keys. " - f"Breakpoint dimensions: {list(bp.dims)}, " - f"expression keys: {list(expr_keys)}" - ) - - -def _build_stacked_expr( - model: Model, - expr_dict: dict[str, LinExprLike], - bp: DataArray, - link_dim: str, -) -> LinearExpression: - """Stack expressions along the link dimension.""" - from linopy.expressions import LinearExpression - - link_coords = list(bp.coords[link_dim].values) - expr_data_list = [] - for k in link_coords: - e = expr_dict[str(k)] - linexpr = _to_linexpr(e) - expr_data_list.append(linexpr.data.expand_dims({link_dim: [k]})) - - stacked_data = xr.concat(expr_data_list, dim=link_dim) - return LinearExpression(stacked_data, model) - - -def _add_pwl_sos2_nvar( - model: Model, - name: str, - bp: DataArray, - dim: str, - target_expr: LinearExpression, - lambda_coords: list[pd.Index], - lambda_mask: DataArray | None, -) -> Constraint: - """SOS2 formulation for N-variable linking.""" - lambda_name = f"{name}{PWL_LAMBDA_SUFFIX}" - convex_name = f"{name}{PWL_CONVEX_SUFFIX}" - link_name = f"{name}{PWL_X_LINK_SUFFIX}" - - lambda_var = model.add_variables( - lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=lambda_mask + # Continuous: stack into N-variable formulation + return _add_continuous_nvar( + model, + name, + lin_exprs, + bp_list, + bp_mask, + method, + skip_nan_check, + active_expr, ) - model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=dim) - - model.add_constraints(lambda_var.sum(dim=dim) == 1, name=convex_name) - - weighted_sum = (lambda_var * bp).sum(dim=dim) - return model.add_constraints(target_expr == weighted_sum, name=link_name) - -def _add_pwl_incremental_nvar( +def _add_continuous_nvar( model: Model, name: str, - bp: DataArray, - dim: str, - target_expr: LinearExpression, - extra_coords: list[pd.Index], + lin_exprs: list[LinearExpression], + bp_list: list[DataArray], bp_mask: DataArray | None, - link_dim: str | None, -) -> Constraint: - """Incremental formulation for N-variable linking.""" - delta_name = f"{name}{PWL_DELTA_SUFFIX}" - fill_name = f"{name}{PWL_FILL_SUFFIX}" - link_name = f"{name}{PWL_X_LINK_SUFFIX}" - - n_segments = bp.sizes[dim] - 1 - seg_dim = f"{dim}_seg" - seg_index = pd.Index(range(n_segments), name=seg_dim) - delta_coords = extra_coords + [seg_index] - - steps = bp.diff(dim).rename({dim: seg_dim}) - steps[seg_dim] = seg_index - - if bp_mask is not None: - bp_mask_agg = bp_mask - if link_dim is not None: - bp_mask_agg = bp_mask_agg.all(dim=link_dim) - mask_lo = bp_mask_agg.isel({dim: slice(None, -1)}).rename({dim: seg_dim}) - mask_hi = bp_mask_agg.isel({dim: slice(1, None)}).rename({dim: seg_dim}) - mask_lo[seg_dim] = seg_index - mask_hi[seg_dim] = seg_index - delta_mask: DataArray | None = mask_lo & mask_hi - else: - delta_mask = None - - delta_var = model.add_variables( - lower=0, upper=1, coords=delta_coords, name=delta_name, mask=delta_mask - ) - - fill_con: Constraint | None = None - if n_segments >= 2: - delta_lo = delta_var.isel({seg_dim: slice(None, -1)}, drop=True) - delta_hi = delta_var.isel({seg_dim: slice(1, None)}, drop=True) - fill_con = model.add_constraints(delta_hi <= delta_lo, name=fill_name) - - bp0 = bp.isel({dim: 0}) - weighted_sum = (delta_var * steps).sum(dim=seg_dim) + bp0 - link_con = model.add_constraints(target_expr == weighted_sum, name=link_name) - - return fill_con if fill_con is not None else link_con - - -def _compute_mask_nvar( - mask: DataArray | None, - bp: DataArray, + method: str, skip_nan_check: bool, -) -> DataArray | None: - """Compute mask from NaN values in breakpoints (N-variable path).""" - if skip_nan_check: - if bool(bp.isnull().any()): - raise ValueError( - "skip_nan_check=True but breakpoints contain NaN. " - "Either remove NaN values or set skip_nan_check=False." - ) - return mask - nan_mask = ~bp.isnull() - if mask is not None: - return mask & nan_mask - return nan_mask if bool(bp.isnull().any()) else None - - -def _add_piecewise_nvar( - model: Model, - exprs: dict[str, LinExprLike], - breakpoints_da: DataArray, - method: str = "auto", - name: str | None = None, - mask: DataArray | None = None, - skip_nan_check: bool = False, + active: LinearExpression | None = None, ) -> Constraint: - """N-variable piecewise constraint with shared lambdas.""" + """Unified continuous piecewise equality for N expressions.""" + from linopy.expressions import LinearExpression + if method not in ("sos2", "incremental", "auto"): raise ValueError( f"method must be 'sos2', 'incremental', or 'auto', got '{method}'" ) + # Stack breakpoints into a single DataArray with a link dimension + link_dim = "_pwl_var" + link_coords = [str(i) for i in range(len(lin_exprs))] + stacked_bp = xr.concat( + [bp.expand_dims({link_dim: [c]}) for bp, c in zip(bp_list, link_coords)], + dim=link_dim, + ) + dim = BREAKPOINT_DIM - if dim not in breakpoints_da.dims: - raise ValueError( - f"breakpoints must have a '{dim}' dimension. " - f"Got dims {list(breakpoints_da.dims)}. " - "Use the breakpoints() factory to create the array." - ) # Auto-detect method if method in ("incremental", "auto"): - is_monotonic = _check_strict_monotonicity(breakpoints_da) - trailing_nan_only = _has_trailing_nan_only(breakpoints_da) + is_monotonic = _check_strict_monotonicity(stacked_bp) + trailing_nan_only = _has_trailing_nan_only(stacked_bp) if method == "auto": method = "incremental" if (is_monotonic and trailing_nan_only) else "sos2" elif not is_monotonic: @@ -1175,94 +991,110 @@ def _add_piecewise_nvar( ) if method == "sos2": - _validate_numeric_breakpoint_coords(breakpoints_da) - - if name is None: - name = f"pwl{model._pwlCounter}" - model._pwlCounter += 1 + _validate_numeric_breakpoint_coords(stacked_bp) + if not _has_trailing_nan_only(stacked_bp): + raise ValueError( + "SOS2 method does not support non-trailing NaN breakpoints." + ) - # Resolve expressions and linking dimension - expr_keys = set(exprs.keys()) - link_dim = _resolve_link_dim(breakpoints_da, expr_keys, {dim}) - computed_mask = _compute_mask_nvar(mask, breakpoints_da, skip_nan_check) + # Stack expressions along the link dimension + expr_data_list = [ + e.data.expand_dims({link_dim: [c]}) for e, c in zip(lin_exprs, link_coords) + ] + stacked_data = xr.concat(expr_data_list, dim=link_dim) + target_expr = LinearExpression(stacked_data, model) + # Compute lambda mask lambda_mask = None - if computed_mask is not None: - if link_dim not in computed_mask.dims: - computed_mask = computed_mask.broadcast_like(breakpoints_da) - lambda_mask = computed_mask.any(dim=link_dim) - - # Broadcast breakpoints to cover expression dimensions (e.g. time) - breakpoints_da = _broadcast_points( - breakpoints_da, *exprs.values(), disjunctive=False - ) + if bp_mask is not None: + stacked_mask = xr.concat( + [bp_mask.expand_dims({link_dim: [c]}) for c in link_coords], + dim=link_dim, + ) + lambda_mask = stacked_mask.any(dim=link_dim) - target_expr = _build_stacked_expr(model, exprs, breakpoints_da, link_dim) - extra = _extra_coords(breakpoints_da, dim, link_dim) - lambda_coords = extra + [pd.Index(breakpoints_da.coords[dim].values, name=dim)] + extra = _extra_coords(stacked_bp, dim, link_dim) + lambda_coords = extra + [pd.Index(stacked_bp.coords[dim].values, name=dim)] + + # Convexity RHS: 1 or active + rhs = active if active is not None else 1 if method == "sos2": - return _add_pwl_sos2_nvar( - model, name, breakpoints_da, dim, target_expr, lambda_coords, lambda_mask - ) - else: - return _add_pwl_incremental_nvar( - model, - name, - breakpoints_da, - dim, - target_expr, - extra, - computed_mask, - link_dim, - ) + lambda_name = f"{name}{PWL_LAMBDA_SUFFIX}" + convex_name = f"{name}{PWL_CONVEX_SUFFIX}" + link_name = f"{name}{PWL_X_LINK_SUFFIX}" + lambda_var = model.add_variables( + lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=lambda_mask + ) + model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=dim) + model.add_constraints(lambda_var.sum(dim=dim) == rhs, name=convex_name) -def _add_continuous( - model: Model, - name: str, - x_expr: LinearExpression, - y_expr: LinearExpression, - x_points: DataArray, - y_points: DataArray, - mask: DataArray | None, - method: str, - skip_nan_check: bool, - active: LinearExpression | None = None, -) -> Constraint: - """Handle continuous (non-disjunctive) piecewise equality constraints.""" - # Determine actual method - if method == "auto": - if _check_strict_monotonicity(x_points) and _has_trailing_nan_only(x_points): - method = "incremental" - else: - method = "sos2" - elif method == "incremental": - if not _check_strict_monotonicity(x_points): - raise ValueError("Incremental method requires strictly monotonic x_points") - if not _has_trailing_nan_only(x_points): - raise ValueError( - "Incremental method does not support non-trailing NaN breakpoints. " - "NaN values must only appear at the end of the breakpoint sequence." - ) + weighted_sum = (lambda_var * stacked_bp).sum(dim=dim) + return model.add_constraints(target_expr == weighted_sum, name=link_name) - if method == "sos2": - _validate_numeric_breakpoint_coords(x_points) - if not _has_trailing_nan_only(x_points): - raise ValueError( - "SOS2 method does not support non-trailing NaN breakpoints. " - "NaN values must only appear at the end of the breakpoint sequence." + else: # incremental + delta_name = f"{name}{PWL_DELTA_SUFFIX}" + fill_name = f"{name}{PWL_FILL_SUFFIX}" + link_name = f"{name}{PWL_X_LINK_SUFFIX}" + inc_binary_name = f"{name}{PWL_INC_BINARY_SUFFIX}" + inc_link_name = f"{name}{PWL_INC_LINK_SUFFIX}" + inc_order_name = f"{name}{PWL_INC_ORDER_SUFFIX}" + + n_segments = stacked_bp.sizes[dim] - 1 + seg_dim = f"{dim}_seg" + seg_index = pd.Index(range(n_segments), name=seg_dim) + delta_extra = _extra_coords(stacked_bp, dim, link_dim) + delta_coords = delta_extra + [seg_index] + + steps = stacked_bp.diff(dim).rename({dim: seg_dim}) + steps[seg_dim] = seg_index + + if bp_mask is not None: + stacked_mask = xr.concat( + [bp_mask.expand_dims({link_dim: [c]}) for c in link_coords], + dim=link_dim, ) + bp_mask_agg = stacked_mask.all(dim=link_dim) + mask_lo = bp_mask_agg.isel({dim: slice(None, -1)}).rename({dim: seg_dim}) + mask_hi = bp_mask_agg.isel({dim: slice(1, None)}).rename({dim: seg_dim}) + mask_lo[seg_dim] = seg_index + mask_hi[seg_dim] = seg_index + delta_mask: DataArray | None = mask_lo & mask_hi + else: + delta_mask = None - # Direct linking: y = f(x) - if method == "sos2": - return _add_pwl_sos2_core( - model, name, x_expr, y_expr, x_points, y_points, mask, active + delta_var = model.add_variables( + lower=0, upper=1, coords=delta_coords, name=delta_name, mask=delta_mask ) - else: # incremental - return _add_pwl_incremental_core( - model, name, x_expr, y_expr, x_points, y_points, mask, active + + if active is not None: + active_bound_name = f"{name}{PWL_ACTIVE_BOUND_SUFFIX}" + model.add_constraints(delta_var <= active, name=active_bound_name) + + binary_var = model.add_variables( + binary=True, coords=delta_coords, name=inc_binary_name, mask=delta_mask ) + model.add_constraints(delta_var <= binary_var, name=inc_link_name) + + fill_con: Constraint | None = None + if n_segments >= 2: + delta_lo = delta_var.isel({seg_dim: slice(None, -1)}, drop=True) + delta_hi = delta_var.isel({seg_dim: slice(1, None)}, drop=True) + fill_con = model.add_constraints(delta_hi <= delta_lo, name=fill_name) + + binary_hi = binary_var.isel({seg_dim: slice(1, None)}, drop=True) + model.add_constraints(binary_hi <= delta_lo, name=inc_order_name) + + bp0 = stacked_bp.isel({dim: 0}) + if active is not None: + bp0_term = bp0 * active + else: + bp0_term = bp0 + weighted_sum = (delta_var * steps).sum(dim=seg_dim) + bp0_term + link_con = model.add_constraints(target_expr == weighted_sum, name=link_name) + + return fill_con if fill_con is not None else link_con def _add_disjunctive( @@ -1276,7 +1108,7 @@ def _add_disjunctive( method: str, active: LinearExpression | None = None, ) -> Constraint: - """Handle disjunctive piecewise equality constraints.""" + """Handle disjunctive piecewise equality constraints (2-variable only).""" if method == "incremental": raise ValueError( "Incremental method is not supported for disjunctive constraints" diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 6b0e46e9..af913769 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -31,7 +31,6 @@ PWL_LAMBDA_SUFFIX, PWL_SELECT_SUFFIX, PWL_X_LINK_SUFFIX, - PWL_Y_LINK_SUFFIX, SEGMENT_DIM, ) from linopy.solver_capabilities import SolverFeature, get_available_solvers_with_feature @@ -284,16 +283,14 @@ def test_sos2(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 10, 50, 100], - y_points=[5, 2, 20, 80], + (x, [0, 10, 50, 100]), + (y, [5, 2, 20, 80]), method="sos2", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables assert f"pwl0{PWL_CONVEX_SUFFIX}" in m.constraints assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints - assert f"pwl0{PWL_Y_LINK_SUFFIX}" in m.constraints + # N-var path uses a single stacked link constraint (no separate y_link) lam = m.variables[f"pwl0{PWL_LAMBDA_SUFFIX}"] assert lam.attrs.get("sos_type") == 2 @@ -301,11 +298,10 @@ def test_auto_selects_incremental_for_monotonic(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") + # Both breakpoint sequences must be monotonic for incremental m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 10, 50, 100], - y_points=[5, 2, 20, 80], + (x, [0, 10, 50, 100]), + (y, [0, 5, 20, 80]), ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables assert f"pwl0{PWL_LAMBDA_SUFFIX}" not in m.variables @@ -314,11 +310,10 @@ def test_auto_nonmonotonic_falls_back_to_sos2(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") + # Non-monotonic y-breakpoints force SOS2 m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 30, 100], - y_points=[5, 20, 15, 80], + (x, [0, 50, 30, 100]), + (y, [5, 20, 15, 80]), ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables assert f"pwl0{PWL_DELTA_SUFFIX}" not in m.variables @@ -329,13 +324,17 @@ def test_multi_dimensional(self) -> None: x = m.add_variables(coords=[gens], name="x") y = m.add_variables(coords=[gens], name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=breakpoints( - {"gen_a": [0, 10, 50], "gen_b": [0, 20, 80]}, dim="generator" + ( + x, + breakpoints( + {"gen_a": [0, 10, 50], "gen_b": [0, 20, 80]}, dim="generator" + ), ), - y_points=breakpoints( - {"gen_a": [0, 5, 30], "gen_b": [0, 8, 50]}, dim="generator" + ( + y, + breakpoints( + {"gen_a": [0, 5, 30], "gen_b": [0, 8, 50]}, dim="generator" + ), ), ) delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"] @@ -345,15 +344,13 @@ def test_with_slopes(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") + # slopes=[-0.3, 0.45, 1.2] with y0=5 -> y_points=[5, 2, 20, 80] + # Non-monotonic y-breakpoints, so auto selects SOS2 m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 10, 50, 100], - y_points=breakpoints( - slopes=[-0.3, 0.45, 1.2], x_points=[0, 10, 50, 100], y0=5 - ), + (x, [0, 10, 50, 100]), + (y, breakpoints(slopes=[-0.3, 0.45, 1.2], x_points=[0, 10, 50, 100], y0=5)), ) - assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables + assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables # =========================================================================== @@ -426,10 +423,8 @@ def test_creates_delta_vars(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 10, 50, 100], - y_points=[5, 2, 20, 80], + (x, [0, 10, 50, 100]), + (y, [5, 10, 20, 80]), method="incremental", ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables @@ -444,10 +439,8 @@ def test_nonmonotonic_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="strictly monotonic"): m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 30, 100], - y_points=[5, 20, 15, 80], + (x, [0, 50, 30, 100]), + (y, [5, 20, 15, 80]), method="incremental", ) @@ -456,10 +449,8 @@ def test_sos2_nonmonotonic_succeeds(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 30, 100], - y_points=[5, 20, 15, 80], + (x, [0, 50, 30, 100]), + (y, [5, 20, 15, 80]), method="sos2", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables @@ -470,26 +461,22 @@ def test_two_breakpoints_no_fill(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 100], - y_points=[5, 80], + (x, [0, 100]), + (y, [5, 80]), method="incremental", ) delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"] assert delta.labels.sizes[LP_SEG_DIM] == 1 assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints - assert f"pwl0{PWL_Y_LINK_SUFFIX}" in m.constraints + # N-var path uses a single stacked link constraint (no separate y_link) def test_creates_binary_indicator_vars(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 10, 50, 100], - y_points=[5, 2, 20, 80], + (x, [0, 10, 50, 100]), + (y, [5, 10, 20, 80]), method="incremental", ) assert f"pwl0{PWL_INC_BINARY_SUFFIX}" in m.variables @@ -502,10 +489,8 @@ def test_creates_order_constraints(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 10, 50, 100], - y_points=[5, 2, 20, 80], + (x, [0, 10, 50, 100]), + (y, [5, 10, 20, 80]), method="incremental", ) assert f"pwl0{PWL_INC_ORDER_SUFFIX}" in m.constraints @@ -516,10 +501,8 @@ def test_two_breakpoints_no_order_constraint(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 100], - y_points=[5, 80], + (x, [0, 100]), + (y, [5, 80]), method="incremental", ) assert f"pwl0{PWL_INC_BINARY_SUFFIX}" in m.variables @@ -531,10 +514,8 @@ def test_decreasing_monotonic(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[100, 50, 10, 0], - y_points=[80, 20, 2, 5], + (x, [100, 50, 10, 0]), + (y, [80, 20, 5, 2]), method="incremental", ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables @@ -551,10 +532,8 @@ def test_equality_creates_binary(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=segments([[0, 10], [50, 100]]), - y_points=segments([[0, 5], [20, 80]]), + (x, segments([[0, 10], [50, 100]])), + (y, segments([[0, 5], [20, 80]])), ) assert f"pwl0{PWL_BINARY_SUFFIX}" in m.variables assert f"pwl0{PWL_SELECT_SUFFIX}" in m.constraints @@ -569,10 +548,8 @@ def test_method_incremental_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="disjunctive"): m.add_piecewise_constraints( - x=x, - y=y, - x_points=segments([[0, 10], [50, 100]]), - y_points=segments([[0, 5], [20, 80]]), + (x, segments([[0, 10], [50, 100]])), + (y, segments([[0, 5], [20, 80]])), method="incremental", ) @@ -582,15 +559,19 @@ def test_multi_dimensional(self) -> None: x = m.add_variables(coords=[gens], name="x") y = m.add_variables(coords=[gens], name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=segments( - {"gen_a": [[0, 10], [50, 100]], "gen_b": [[0, 20], [60, 90]]}, - dim="generator", + ( + x, + segments( + {"gen_a": [[0, 10], [50, 100]], "gen_b": [[0, 20], [60, 90]]}, + dim="generator", + ), ), - y_points=segments( - {"gen_a": [[0, 5], [20, 80]], "gen_b": [[0, 8], [30, 70]]}, - dim="generator", + ( + y, + segments( + {"gen_a": [[0, 5], [20, 80]], "gen_b": [[0, 8], [30, 70]]}, + dim="generator", + ), ), ) binary = m.variables[f"pwl0{PWL_BINARY_SUFFIX}"] @@ -608,8 +589,8 @@ class TestValidation: def test_wrong_arg_types_raises(self) -> None: m = Model() x = m.add_variables(name="x") - with pytest.raises(TypeError, match="requires either"): - m.add_piecewise_constraints(x=x) # type: ignore + with pytest.raises(TypeError, match="at least 2"): + m.add_piecewise_constraints((x, [0, 10, 50])) def test_invalid_method_raises(self) -> None: m = Model() @@ -617,10 +598,8 @@ def test_invalid_method_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="method must be"): m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 10, 50], - y_points=[5, 2, 20], + (x, [0, 10, 50]), + (y, [5, 10, 20]), method="invalid", # type: ignore ) @@ -636,10 +615,8 @@ def test_auto_name(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") z = m.add_variables(name="z") - m.add_piecewise_constraints(x=x, y=y, x_points=[0, 10, 50], y_points=[5, 2, 20]) - m.add_piecewise_constraints( - x=x, y=z, x_points=[0, 20, 80], y_points=[10, 15, 50] - ) + m.add_piecewise_constraints((x, [0, 10, 50]), (y, [5, 10, 20])) + m.add_piecewise_constraints((x, [0, 20, 80]), (z, [10, 15, 50])) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables assert f"pwl1{PWL_DELTA_SUFFIX}" in m.variables @@ -648,15 +625,13 @@ def test_custom_name(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 10, 50], - y_points=[5, 2, 20], + (x, [0, 10, 50]), + (y, [5, 10, 20]), name="my_pwl", ) assert f"my_pwl{PWL_DELTA_SUFFIX}" in m.variables assert f"my_pwl{PWL_X_LINK_SUFFIX}" in m.constraints - assert f"my_pwl{PWL_Y_LINK_SUFFIX}" in m.constraints + # N-var path uses a single stacked link constraint (no separate y_link) # =========================================================================== @@ -673,13 +648,17 @@ def test_broadcast_over_extra_dims(self) -> None: y = m.add_variables(coords=[gens, times], name="y") # Points only have generator dim -> broadcast over time m.add_piecewise_constraints( - x=x, - y=y, - x_points=breakpoints( - {"gen_a": [0, 10, 50], "gen_b": [0, 20, 80]}, dim="generator" + ( + x, + breakpoints( + {"gen_a": [0, 10, 50], "gen_b": [0, 20, 80]}, dim="generator" + ), ), - y_points=breakpoints( - {"gen_a": [0, 5, 30], "gen_b": [0, 8, 50]}, dim="generator" + ( + y, + breakpoints( + {"gen_a": [0, 5, 30], "gen_b": [0, 8, 50]}, dim="generator" + ), ), ) delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"] @@ -701,10 +680,8 @@ def test_nan_masks_lambda_labels(self) -> None: x_pts = xr.DataArray([0, 10, 50, np.nan], dims=[BREAKPOINT_DIM]) y_pts = xr.DataArray([0, 5, 20, np.nan], dims=[BREAKPOINT_DIM]) m.add_piecewise_constraints( - x=x, - y=y, - x_points=x_pts, - y_points=y_pts, + (x, x_pts), + (y, y_pts), method="sos2", ) lam = m.variables[f"pwl0{PWL_LAMBDA_SUFFIX}"] @@ -721,10 +698,8 @@ def test_skip_nan_check_with_nan_raises(self) -> None: y_pts = xr.DataArray([0, 5, 20, np.nan], dims=[BREAKPOINT_DIM]) with pytest.raises(ValueError, match="skip_nan_check=True but breakpoints"): m.add_piecewise_constraints( - x=x, - y=y, - x_points=x_pts, - y_points=y_pts, + (x, x_pts), + (y, y_pts), method="sos2", skip_nan_check=True, ) @@ -737,10 +712,8 @@ def test_skip_nan_check_without_nan(self) -> None: x_pts = xr.DataArray([0, 10, 50, 100], dims=[BREAKPOINT_DIM]) y_pts = xr.DataArray([0, 5, 20, 40], dims=[BREAKPOINT_DIM]) m.add_piecewise_constraints( - x=x, - y=y, - x_points=x_pts, - y_points=y_pts, + (x, x_pts), + (y, y_pts), method="sos2", skip_nan_check=True, ) @@ -756,10 +729,8 @@ def test_sos2_interior_nan_raises(self) -> None: y_pts = xr.DataArray([0, np.nan, 20, 40], dims=[BREAKPOINT_DIM]) with pytest.raises(ValueError, match="non-trailing NaN"): m.add_piecewise_constraints( - x=x, - y=y, - x_points=x_pts, - y_points=y_pts, + (x, x_pts), + (y, y_pts), method="sos2", ) @@ -775,10 +746,8 @@ def test_sos2_equality(self, tmp_path: Path) -> None: x = m.add_variables(name="x", lower=0, upper=100) y = m.add_variables(name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0.0, 10.0, 50.0, 100.0], - y_points=[5.0, 2.0, 20.0, 80.0], + (x, [0.0, 10.0, 50.0, 100.0]), + (y, [5.0, 2.0, 20.0, 80.0]), method="sos2", ) m.add_objective(y) @@ -793,10 +762,8 @@ def test_disjunctive_sos2_and_binary(self, tmp_path: Path) -> None: x = m.add_variables(name="x", lower=0, upper=100) y = m.add_variables(name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=segments([[0.0, 10.0], [50.0, 100.0]]), - y_points=segments([[0.0, 5.0], [20.0, 80.0]]), + (x, segments([[0.0, 10.0], [50.0, 100.0]])), + (y, segments([[0.0, 5.0], [20.0, 80.0]])), ) m.add_objective(y) fn = tmp_path / "pwl_disj.lp" @@ -822,10 +789,8 @@ def test_equality_minimize_cost(self, solver_name: str) -> None: x = m.add_variables(lower=0, upper=100, name="x") cost = m.add_variables(name="cost") m.add_piecewise_constraints( - x=x, - y=cost, - x_points=[0, 50, 100], - y_points=[0, 10, 50], + (x, [0, 50, 100]), + (cost, [0, 10, 50]), ) m.add_constraints(x >= 50, name="x_min") m.add_objective(cost) @@ -839,10 +804,8 @@ def test_equality_maximize_efficiency(self, solver_name: str) -> None: power = m.add_variables(lower=0, upper=100, name="power") eff = m.add_variables(name="eff") m.add_piecewise_constraints( - x=power, - y=eff, - x_points=[0, 25, 50, 75, 100], - y_points=[0.7, 0.85, 0.95, 0.9, 0.8], + (power, [0, 25, 50, 75, 100]), + (eff, [0.7, 0.85, 0.95, 0.9, 0.8]), ) m.add_objective(eff, sense="max") status, _ = m.solve(solver_name=solver_name) @@ -855,10 +818,8 @@ def test_disjunctive_solve(self, solver_name: str) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=segments([[0.0, 10.0], [50.0, 100.0]]), - y_points=segments([[0.0, 5.0], [20.0, 80.0]]), + (x, segments([[0.0, 10.0], [50.0, 100.0]])), + (y, segments([[0.0, 5.0], [20.0, 80.0]])), ) m.add_constraints(x >= 60, name="x_min") m.add_objective(y) @@ -962,10 +923,8 @@ def test_incremental_creates_active_bound(self) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 10, 50, 100], - y_points=[5, 2, 20, 80], + (x, [0, 10, 50, 100]), + (y, [5, 10, 20, 80]), active=u, method="incremental", ) @@ -978,10 +937,8 @@ def test_active_none_is_default(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 10, 50], - y_points=[0, 5, 30], + (x, [0, 10, 50]), + (y, [0, 5, 30]), method="incremental", ) assert f"pwl0{PWL_ACTIVE_BOUND_SUFFIX}" not in m.constraints @@ -993,10 +950,8 @@ def test_active_with_linear_expression(self) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 10, 50], + (x, [0, 50, 100]), + (y, [0, 10, 50]), active=1 * u, method="incremental", ) @@ -1021,10 +976,8 @@ def test_incremental_active_on(self, solver_name: str) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 10, 50], + (x, [0, 50, 100]), + (y, [0, 10, 50]), active=u, method="incremental", ) @@ -1043,10 +996,8 @@ def test_incremental_active_off(self, solver_name: str) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 10, 50], + (x, [0, 50, 100]), + (y, [0, 10, 50]), active=u, method="incremental", ) @@ -1069,10 +1020,8 @@ def test_incremental_nonzero_base_active_off(self, solver_name: str) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[20, 60, 100], - y_points=[5, 20, 50], + (x, [20, 60, 100]), + (y, [5, 20, 50]), active=u, method="incremental", ) @@ -1094,10 +1043,8 @@ def test_unit_commitment_pattern(self, solver_name: str) -> None: u = m.add_variables(binary=True, name="commit") m.add_piecewise_constraints( - x=power, - y=fuel, - x_points=[p_min, p_max], - y_points=[fuel_at_pmin, fuel_at_pmax], + (power, [p_min, p_max]), + (fuel, [fuel_at_pmin, fuel_at_pmax]), active=u, method="incremental", ) @@ -1119,10 +1066,8 @@ def test_multi_dimensional_solver(self, solver_name: str) -> None: y = m.add_variables(coords=[gens], name="y") u = m.add_variables(binary=True, coords=[gens], name="u") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 10, 50], + (x, [0, 50, 100]), + (y, [0, 10, 50]), active=u, method="incremental", ) @@ -1151,10 +1096,8 @@ def test_sos2_active_off(self, solver_name: str) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x=x, - y=y, - x_points=[0, 50, 100], - y_points=[0, 10, 50], + (x, [0, 50, 100]), + (y, [0, 10, 50]), active=u, method="sos2", ) @@ -1172,10 +1115,8 @@ def test_disjunctive_active_off(self, solver_name: str) -> None: y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") m.add_piecewise_constraints( - x=x, - y=y, - x_points=segments([[0.0, 10.0], [50.0, 100.0]]), - y_points=segments([[0.0, 5.0], [20.0, 80.0]]), + (x, segments([[0.0, 10.0], [50.0, 100.0]])), + (y, segments([[0.0, 5.0], [20.0, 80.0]])), active=u, ) m.add_constraints(u <= 0, name="force_off") @@ -1192,24 +1133,15 @@ def test_disjunctive_active_off(self, solver_name: str) -> None: class TestNVariable: - """Tests for the N-variable (dict-based) piecewise constraint API.""" - - def _make_chp_breakpoints(self) -> xr.DataArray: - """Create a 2-variable breakpoint array for a CHP-like problem.""" - return xr.DataArray( - [[0.0, 50.0, 100.0], [0.0, 20.0, 60.0]], - dims=["var", BREAKPOINT_DIM], - coords={"var": ["power", "fuel"], BREAKPOINT_DIM: [0, 1, 2]}, - ) + """Tests for the N-variable tuple-based piecewise constraint API.""" def test_sos2_creates_lambda_and_link(self) -> None: m = Model() power = m.add_variables(lower=0, upper=100, name="power") fuel = m.add_variables(name="fuel") - bp = self._make_chp_breakpoints() m.add_piecewise_constraints( - exprs={"power": power, "fuel": fuel}, - breakpoints=bp, + (power, [0.0, 50.0, 100.0]), + (fuel, [0.0, 20.0, 60.0]), method="sos2", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables @@ -1220,10 +1152,9 @@ def test_incremental_creates_delta(self) -> None: m = Model() power = m.add_variables(lower=0, upper=100, name="power") fuel = m.add_variables(name="fuel") - bp = self._make_chp_breakpoints() m.add_piecewise_constraints( - exprs={"power": power, "fuel": fuel}, - breakpoints=bp, + (power, [0.0, 50.0, 100.0]), + (fuel, [0.0, 20.0, 60.0]), method="incremental", ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables @@ -1233,20 +1164,19 @@ def test_auto_selects_method(self) -> None: m = Model() power = m.add_variables(lower=0, upper=100, name="power") fuel = m.add_variables(name="fuel") - bp = self._make_chp_breakpoints() m.add_piecewise_constraints( - exprs={"power": power, "fuel": fuel}, - breakpoints=bp, + (power, [0.0, 50.0, 100.0]), + (fuel, [0.0, 20.0, 60.0]), ) # Auto should select incremental for monotonic breakpoints assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables - def test_missing_breakpoints_raises(self) -> None: + def test_single_pair_raises(self) -> None: m = Model() power = m.add_variables(name="power") - with pytest.raises(TypeError, match="both 'exprs' and 'breakpoints'"): + with pytest.raises(TypeError, match="at least 2"): m.add_piecewise_constraints( - exprs={"power": power}, + (power, [0.0, 50.0, 100.0]), ) def test_three_variables(self) -> None: @@ -1254,60 +1184,25 @@ def test_three_variables(self) -> None: power = m.add_variables(lower=0, upper=100, name="power") fuel = m.add_variables(name="fuel") heat = m.add_variables(name="heat") - bp = xr.DataArray( - [[0.0, 50.0, 100.0], [0.0, 20.0, 60.0], [0.0, 30.0, 80.0]], - dims=["var", BREAKPOINT_DIM], - coords={"var": ["power", "fuel", "heat"], BREAKPOINT_DIM: [0, 1, 2]}, - ) m.add_piecewise_constraints( - exprs={"power": power, "fuel": fuel, "heat": heat}, - breakpoints=bp, + (power, [0.0, 50.0, 100.0]), + (fuel, [0.0, 20.0, 60.0]), + (heat, [0.0, 30.0, 80.0]), method="sos2", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints - # link constraint should have var dimension + # link constraint should have _pwl_var dimension link = m.constraints[f"pwl0{PWL_X_LINK_SUFFIX}"] - assert "var" in link.labels.dims + assert "_pwl_var" in link.labels.dims def test_custom_name(self) -> None: m = Model() power = m.add_variables(lower=0, upper=100, name="power") fuel = m.add_variables(name="fuel") - bp = self._make_chp_breakpoints() m.add_piecewise_constraints( - exprs={"power": power, "fuel": fuel}, - breakpoints=bp, + (power, [0.0, 50.0, 100.0]), + (fuel, [0.0, 20.0, 60.0]), name="chp", ) assert f"chp{PWL_DELTA_SUFFIX}" in m.variables - - def test_missing_breakpoint_dim_raises(self) -> None: - m = Model() - power = m.add_variables(name="power") - fuel = m.add_variables(name="fuel") - bp = xr.DataArray( - [[0.0, 50.0], [0.0, 20.0]], - dims=["var", "knot"], - coords={"var": ["power", "fuel"], "knot": [0, 1]}, - ) - with pytest.raises(ValueError, match="must have a"): - m.add_piecewise_constraints( - exprs={"power": power, "fuel": fuel}, - breakpoints=bp, - ) - - def test_link_dim_mismatch_raises(self) -> None: - m = Model() - power = m.add_variables(name="power") - fuel = m.add_variables(name="fuel") - bp = xr.DataArray( - [[0.0, 50.0], [0.0, 20.0]], - dims=["wrong", BREAKPOINT_DIM], - coords={"wrong": ["a", "b"], BREAKPOINT_DIM: [0, 1]}, - ) - with pytest.raises(ValueError, match="Could not auto-detect"): - m.add_piecewise_constraints( - exprs={"power": power, "fuel": fuel}, - breakpoints=bp, - ) From 786776d39b0d87c8bf93513a2d9a91ad5e85f140 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:17:50 +0200 Subject: [PATCH 13/30] feat: use variable names as link dimension coordinates The _pwl_var dimension now shows variable names (e.g. "power", "fuel") instead of generic indices ("0", "1"), making generated constraints easier to debug and inspect. Co-Authored-By: Claude Opus 4.6 (1M context) --- linopy/piecewise.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 15efccf4..14e04d7b 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -911,6 +911,16 @@ def add_piecewise_constraints( name = f"pwl{model._pwlCounter}" model._pwlCounter += 1 + # Build link dimension coordinates from variable names + from linopy.variables import Variable + + link_coords: list[str] = [] + for i, expr in enumerate(all_exprs): + if isinstance(expr, Variable) and expr.name: + link_coords.append(expr.name) + else: + link_coords.append(str(i)) + # Convert expressions to LinearExpressions lin_exprs = [_to_linexpr(expr) for expr in all_exprs] active_expr = _to_linexpr(active) if active is not None else None @@ -940,6 +950,7 @@ def add_piecewise_constraints( name, lin_exprs, bp_list, + link_coords, bp_mask, method, skip_nan_check, @@ -952,6 +963,7 @@ def _add_continuous_nvar( name: str, lin_exprs: list[LinearExpression], bp_list: list[DataArray], + link_coords: list[str], bp_mask: DataArray | None, method: str, skip_nan_check: bool, @@ -967,7 +979,6 @@ def _add_continuous_nvar( # Stack breakpoints into a single DataArray with a link dimension link_dim = "_pwl_var" - link_coords = [str(i) for i in range(len(lin_exprs))] stacked_bp = xr.concat( [bp.expand_dims({link_dim: [c]}) for bp, c in zip(bp_list, link_coords)], dim=link_dim, From d3b21a072c1ab3f709aa89bb9a407a776473ca8b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:20:10 +0200 Subject: [PATCH 14/30] fix: remove piecewise.piecewise from api.rst, fix xr.concat compat in notebook The piecewise() function was removed but api.rst still referenced it. Also replace xr.concat with breakpoints() in plot cells to avoid pandas StringDtype compatibility issue on newer xarray. Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/api.rst | 2 +- examples/piecewise-linear-constraints.ipynb | 8253 +++++++++++++++---- 2 files changed, 6589 insertions(+), 1666 deletions(-) diff --git a/doc/api.rst b/doc/api.rst index 1554ce60..1ad7d869 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -19,9 +19,9 @@ Creating a model model.Model.add_constraints model.Model.add_objective model.Model.add_piecewise_constraints - piecewise.piecewise piecewise.breakpoints piecewise.segments + piecewise.tangent_lines model.Model.linexpr model.Model.remove_constraints model.Model.copy diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index ff32c4b3..742f200c 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -3,1006 +3,6094 @@ { "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 | Tangent lines |\n| 5 | Gas unit with commitment | On/off + min load | Incremental + `active` |\n| 6 | CHP plant (N-variable) | Joint power/fuel/heat | N-variable SOS2 |\n\n**API:** Each `(expression, breakpoints)` tuple links a variable to its breakpoints.\nAll tuples share interpolation weights, coupling them on the same curve segment.\n\n```python\nm.add_piecewise_constraints((power, x_pts), (fuel, y_pts))\n```" - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.292583Z", - "start_time": "2026-04-01T07:35:36.286274Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.167007Z", - "iopub.status.busy": "2026-03-06T11:51:29.166576Z", - "iopub.status.idle": "2026-03-06T11:51:29.185103Z", - "shell.execute_reply": "2026-03-06T11:51:29.184712Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.166974Z" - } - }, - "outputs": [], "source": [ - "import matplotlib.pyplot as plt\n", - "import pandas as pd\n", - "import xarray as xr\n", + "#", + " ", + "P", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + " ", + "L", + "i", + "n", + "e", + "a", + "r", + " ", + "C", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + " ", + "T", + "u", + "t", + "o", + "r", + "i", + "a", + "l", "\n", - "import linopy\n", "\n", - "time = pd.Index([1, 2, 3], name=\"time\")\n", + "T", + "h", + "i", + "s", + " ", + "n", + "o", + "t", + "e", + "b", + "o", + "o", + "k", + " ", + "d", + "e", + "m", + "o", + "n", + "s", + "t", + "r", + "a", + "t", + "e", + "s", + " ", + "l", + "i", + "n", + "o", + "p", + "y", + "'", + "s", + " ", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + " ", + "l", + "i", + "n", + "e", + "a", + "r", + " ", + "(", + "P", + "W", + "L", + ")", + " ", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + " ", + "f", + "o", + "r", + "m", + "u", + "l", + "a", + "t", + "i", + "o", + "n", + "s", + ".", "\n", + "E", + "a", + "c", + "h", + " ", + "e", + "x", + "a", + "m", + "p", + "l", + "e", + " ", + "b", + "u", + "i", + "l", + "d", + "s", + " ", + "a", + " ", + "s", + "e", + "p", + "a", + "r", + "a", + "t", + "e", + " ", + "d", + "i", + "s", + "p", + "a", + "t", + "c", + "h", + " ", + "m", + "o", + "d", + "e", + "l", + " ", + "w", + "h", + "e", + "r", + "e", + " ", + "a", + " ", + "s", + "i", + "n", + "g", + "l", + "e", + " ", + "p", + "o", + "w", + "e", + "r", + " ", + "p", + "l", + "a", + "n", + "t", + " ", + "m", + "u", + "s", + "t", + " ", + "m", + "e", + "e", + "t", "\n", - "def plot_pwl_results(model, breakpoints, demand, *, x_name=\"power\", color=\"C0\"):\n", - " \"\"\"\n", - " Plot PWL curves with operating points and dispatch vs demand.\n", + "a", + " ", + "t", + "i", + "m", + "e", + "-", + "v", + "a", + "r", + "y", + "i", + "n", + "g", + " ", + "d", + "e", + "m", + "a", + "n", + "d", + ".", "\n", - " Parameters\n", - " ----------\n", - " model : linopy.Model\n", - " Solved model.\n", - " breakpoints : DataArray\n", - " Breakpoints array. For 2-variable cases pass a DataArray with a\n", - " \"var\" dimension containing two coordinates (x and y variable names).\n", - " Alternatively pass two separate arrays and they will be stacked.\n", - " demand : DataArray\n", - " Demand time series (plotted as step line).\n", - " x_name : str\n", - " Name of the x-axis variable (used for the curve plot).\n", - " color : str\n", - " Base color for the plot.\n", - " \"\"\"\n", - " sol = model.solution\n", - " var_names = list(breakpoints.coords[\"var\"].values)\n", - " bp_x = breakpoints.sel(var=x_name).values\n", "\n", - " fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3.5))\n", + "|", + " ", + "E", + "x", + "a", + "m", + "p", + "l", + "e", + " ", + "|", + " ", + "P", + "l", + "a", + "n", + "t", + " ", + "|", + " ", + "L", + "i", + "m", + "i", + "t", + "a", + "t", + "i", + "o", + "n", + " ", + "|", + " ", + "F", + "o", + "r", + "m", + "u", + "l", + "a", + "t", + "i", + "o", + "n", + " ", + "|", "\n", - " # Left: breakpoint curves with operating points\n", - " colors = [f\"C{i}\" for i in range(len(var_names))]\n", - " for var, c in zip(var_names, colors):\n", - " if var == x_name:\n", - " continue\n", - " bp_y = breakpoints.sel(var=var).values\n", - " ax1.plot(bp_x, bp_y, \"o-\", color=c, label=f\"{var} (breakpoints)\")\n", - " for t in time:\n", - " ax1.plot(\n", - " float(sol[x_name].sel(time=t)),\n", - " float(sol[var].sel(time=t)),\n", - " \"D\",\n", - " color=c,\n", - " ms=10,\n", - " )\n", - " ax1.set(xlabel=x_name.title(), title=\"PWL curve\")\n", - " ax1.legend()\n", + "|", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "|", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "|", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "|", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "-", + "|", "\n", - " # Right: dispatch vs demand\n", - " x = list(range(len(time)))\n", - " power_vals = sol[x_name].values\n", - " ax2.bar(x, power_vals, color=color, label=x_name.title())\n", - " if \"backup\" in sol:\n", - " ax2.bar(\n", - " x,\n", - " sol[\"backup\"].values,\n", - " bottom=power_vals,\n", - " color=\"C3\",\n", - " alpha=0.5,\n", - " label=\"Backup\",\n", - " )\n", - " ax2.step(\n", - " [v - 0.5 for v in x] + [x[-1] + 0.5],\n", - " list(demand.values) + [demand.values[-1]],\n", - " where=\"post\",\n", - " color=\"black\",\n", - " lw=2,\n", - " label=\"Demand\",\n", - " )\n", - " ax2.set(\n", - " xlabel=\"Time\",\n", - " ylabel=\"MW\",\n", - " title=\"Dispatch\",\n", - " xticks=x,\n", - " xticklabels=time.values,\n", - " )\n", - " ax2.legend()\n", - " plt.tight_layout()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 1. SOS2 formulation — Gas turbine\n", + "|", + " ", + "1", + " ", + "|", + " ", + "G", + "a", + "s", + " ", + "t", + "u", + "r", + "b", + "i", + "n", + "e", + " ", + "(", + "0", + "-", + "1", + "0", + "0", + " ", + "M", + "W", + ")", + " ", + "|", + " ", + "C", + "o", + "n", + "v", + "e", + "x", + " ", + "h", + "e", + "a", + "t", + " ", + "r", + "a", + "t", + "e", + " ", + "|", + " ", + "S", + "O", + "S", + "2", + " ", + "|", "\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", - "to link power output and fuel consumption via separate x/y breakpoints." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.312257Z", - "start_time": "2026-04-01T07:35:36.308964Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.185693Z", - "iopub.status.busy": "2026-03-06T11:51:29.185601Z", - "iopub.status.idle": "2026-03-06T11:51:29.199760Z", - "shell.execute_reply": "2026-03-06T11:51:29.199416Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.185683Z" - } - }, - "outputs": [], - "source": [ - "x_pts1 = linopy.breakpoints([0, 30, 60, 100])\n", - "y_pts1 = linopy.breakpoints([0, 36, 84, 170])\n", - "print(\"x_pts:\", x_pts1.values)\n", - "print(\"y_pts:\", y_pts1.values)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.365214Z", - "start_time": "2026-04-01T07:35:36.322511Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.200170Z", - "iopub.status.busy": "2026-03-06T11:51:29.200087Z", - "iopub.status.idle": "2026-03-06T11:51:29.266847Z", - "shell.execute_reply": "2026-03-06T11:51:29.266379Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.200161Z" - } - }, - "outputs": [], - "source": "m1 = linopy.Model()\n\npower = m1.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\nfuel = m1.add_variables(name=\"fuel\", lower=0, coords=[time])\n\n# breakpoints are auto-broadcast to match the time dimension\nm1.add_piecewise_constraints(\n (power, x_pts1),\n (fuel, y_pts1),\n name=\"pwl\",\n method=\"sos2\",\n)\n\ndemand1 = xr.DataArray([50, 80, 30], coords=[time])\nm1.add_constraints(power >= demand1, name=\"demand\")\nm1.add_objective(fuel.sum())" - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.410875Z", - "start_time": "2026-04-01T07:35:36.367557Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.267522Z", - "iopub.status.busy": "2026-03-06T11:51:29.267433Z", - "iopub.status.idle": "2026-03-06T11:51:29.326758Z", - "shell.execute_reply": "2026-03-06T11:51:29.326518Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.267514Z" - } - }, - "outputs": [], - "source": [ - "m1.solve(reformulate_sos=\"auto\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { + "|", + " ", + "2", + " ", + "|", + " ", + "C", + "o", + "a", + "l", + " ", + "p", + "l", + "a", + "n", + "t", + " ", + "(", + "0", + "-", + "1", + "5", + "0", + " ", + "M", + "W", + ")", + " ", + "|", + " ", + "M", + "o", + "n", + "o", + "t", + "o", + "n", + "i", + "c", + " ", + "h", + "e", + "a", + "t", + " ", + "r", + "a", + "t", + "e", + " ", + "|", + " ", + "I", + "n", + "c", + "r", + "e", + "m", + "e", + "n", + "t", + "a", + "l", + " ", + "|", + "\n", + "|", + " ", + "3", + " ", + "|", + " ", + "D", + "i", + "e", + "s", + "e", + "l", + " ", + "g", + "e", + "n", + "e", + "r", + "a", + "t", + "o", + "r", + " ", + "(", + "o", + "f", + "f", + " ", + "o", + "r", + " ", + "5", + "0", + "-", + "8", + "0", + " ", + "M", + "W", + ")", + " ", + "|", + " ", + "F", + "o", + "r", + "b", + "i", + "d", + "d", + "e", + "n", + " ", + "z", + "o", + "n", + "e", + " ", + "|", + " ", + "D", + "i", + "s", + "j", + "u", + "n", + "c", + "t", + "i", + "v", + "e", + " ", + "|", + "\n", + "|", + " ", + "4", + " ", + "|", + " ", + "C", + "o", + "n", + "c", + "a", + "v", + "e", + " ", + "e", + "f", + "f", + "i", + "c", + "i", + "e", + "n", + "c", + "y", + " ", + "c", + "u", + "r", + "v", + "e", + " ", + "|", + " ", + "I", + "n", + "e", + "q", + "u", + "a", + "l", + "i", + "t", + "y", + " ", + "b", + "o", + "u", + "n", + "d", + " ", + "|", + " ", + "T", + "a", + "n", + "g", + "e", + "n", + "t", + " ", + "l", + "i", + "n", + "e", + "s", + " ", + "|", + "\n", + "|", + " ", + "5", + " ", + "|", + " ", + "G", + "a", + "s", + " ", + "u", + "n", + "i", + "t", + " ", + "w", + "i", + "t", + "h", + " ", + "c", + "o", + "m", + "m", + "i", + "t", + "m", + "e", + "n", + "t", + " ", + "|", + " ", + "O", + "n", + "/", + "o", + "f", + "f", + " ", + "+", + " ", + "m", + "i", + "n", + " ", + "l", + "o", + "a", + "d", + " ", + "|", + " ", + "I", + "n", + "c", + "r", + "e", + "m", + "e", + "n", + "t", + "a", + "l", + " ", + "+", + " ", + "`", + "a", + "c", + "t", + "i", + "v", + "e", + "`", + " ", + "|", + "\n", + "|", + " ", + "6", + " ", + "|", + " ", + "C", + "H", + "P", + " ", + "p", + "l", + "a", + "n", + "t", + " ", + "(", + "N", + "-", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + ")", + " ", + "|", + " ", + "J", + "o", + "i", + "n", + "t", + " ", + "p", + "o", + "w", + "e", + "r", + "/", + "f", + "u", + "e", + "l", + "/", + "h", + "e", + "a", + "t", + " ", + "|", + " ", + "N", + "-", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + " ", + "S", + "O", + "S", + "2", + " ", + "|", + "\n", + "\n", + "*", + "*", + "A", + "P", + "I", + ":", + "*", + "*", + " ", + "E", + "a", + "c", + "h", + " ", + "`", + "(", + "e", + "x", + "p", + "r", + "e", + "s", + "s", + "i", + "o", + "n", + ",", + " ", + "b", + "r", + "e", + "a", + "k", + "p", + "o", + "i", + "n", + "t", + "s", + ")", + "`", + " ", + "t", + "u", + "p", + "l", + "e", + " ", + "l", + "i", + "n", + "k", + "s", + " ", + "a", + " ", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + " ", + "t", + "o", + " ", + "i", + "t", + "s", + " ", + "b", + "r", + "e", + "a", + "k", + "p", + "o", + "i", + "n", + "t", + "s", + ".", + "\n", + "A", + "l", + "l", + " ", + "t", + "u", + "p", + "l", + "e", + "s", + " ", + "s", + "h", + "a", + "r", + "e", + " ", + "i", + "n", + "t", + "e", + "r", + "p", + "o", + "l", + "a", + "t", + "i", + "o", + "n", + " ", + "w", + "e", + "i", + "g", + "h", + "t", + "s", + ",", + " ", + "c", + "o", + "u", + "p", + "l", + "i", + "n", + "g", + " ", + "t", + "h", + "e", + "m", + " ", + "o", + "n", + " ", + "t", + "h", + "e", + " ", + "s", + "a", + "m", + "e", + " ", + "c", + "u", + "r", + "v", + "e", + " ", + "s", + "e", + "g", + "m", + "e", + "n", + "t", + ".", + "\n", + "\n", + "`", + "`", + "`", + "p", + "y", + "t", + "h", + "o", + "n", + "\n", + "m", + ".", + "a", + "d", + "d", + "_", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + "(", + "p", + "o", + "w", + "e", + "r", + ",", + " ", + "x", + "_", + "p", + "t", + "s", + ")", + ",", + " ", + "(", + "f", + "u", + "e", + "l", + ",", + " ", + "y", + "_", + "p", + "t", + "s", + ")", + ")", + "\n", + "`", + "`", + "`" + ] + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.167007Z", + "iopub.status.busy": "2026-03-06T11:51:29.166576Z", + "iopub.status.idle": "2026-03-06T11:51:29.185103Z", + "shell.execute_reply": "2026-03-06T11:51:29.184712Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.166974Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:43.561021Z", + "start_time": "2026-04-01T10:19:42.543401Z" + } + }, + "source": [ + "import matplotlib.pyplot as plt\n", + "import pandas as pd\n", + "import xarray as xr\n", + "\n", + "import linopy\n", + "\n", + "time = pd.Index([1, 2, 3], name=\"time\")\n", + "\n", + "\n", + "def plot_pwl_results(model, breakpoints, demand, *, x_name=\"power\", color=\"C0\"):\n", + " \"\"\"\n", + " Plot PWL curves with operating points and dispatch vs demand.\n", + "\n", + " Parameters\n", + " ----------\n", + " model : linopy.Model\n", + " Solved model.\n", + " breakpoints : DataArray\n", + " Breakpoints array. For 2-variable cases pass a DataArray with a\n", + " \"var\" dimension containing two coordinates (x and y variable names).\n", + " Alternatively pass two separate arrays and they will be stacked.\n", + " demand : DataArray\n", + " Demand time series (plotted as step line).\n", + " x_name : str\n", + " Name of the x-axis variable (used for the curve plot).\n", + " color : str\n", + " Base color for the plot.\n", + " \"\"\"\n", + " sol = model.solution\n", + " var_names = list(breakpoints.coords[\"var\"].values)\n", + " bp_x = breakpoints.sel(var=x_name).values\n", + "\n", + " fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3.5))\n", + "\n", + " # Left: breakpoint curves with operating points\n", + " colors = [f\"C{i}\" for i in range(len(var_names))]\n", + " for var, c in zip(var_names, colors):\n", + " if var == x_name:\n", + " continue\n", + " bp_y = breakpoints.sel(var=var).values\n", + " ax1.plot(bp_x, bp_y, \"o-\", color=c, label=f\"{var} (breakpoints)\")\n", + " for t in time:\n", + " ax1.plot(\n", + " float(sol[x_name].sel(time=t)),\n", + " float(sol[var].sel(time=t)),\n", + " \"D\",\n", + " color=c,\n", + " ms=10,\n", + " )\n", + " ax1.set(xlabel=x_name.title(), title=\"PWL curve\")\n", + " ax1.legend()\n", + "\n", + " # Right: dispatch vs demand\n", + " x = list(range(len(time)))\n", + " power_vals = sol[x_name].values\n", + " ax2.bar(x, power_vals, color=color, label=x_name.title())\n", + " if \"backup\" in sol:\n", + " ax2.bar(\n", + " x,\n", + " sol[\"backup\"].values,\n", + " bottom=power_vals,\n", + " color=\"C3\",\n", + " alpha=0.5,\n", + " label=\"Backup\",\n", + " )\n", + " ax2.step(\n", + " [v - 0.5 for v in x] + [x[-1] + 0.5],\n", + " list(demand.values) + [demand.values[-1]],\n", + " where=\"post\",\n", + " color=\"black\",\n", + " lw=2,\n", + " label=\"Demand\",\n", + " )\n", + " ax2.set(\n", + " xlabel=\"Time\",\n", + " ylabel=\"MW\",\n", + " title=\"Dispatch\",\n", + " xticks=x,\n", + " xticklabels=time.values,\n", + " )\n", + " ax2.legend()\n", + " plt.tight_layout()" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. SOS2 formulation — 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", + "to link power output and fuel consumption via separate x/y breakpoints." + ] + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.185693Z", + "iopub.status.busy": "2026-03-06T11:51:29.185601Z", + "iopub.status.idle": "2026-03-06T11:51:29.199760Z", + "shell.execute_reply": "2026-03-06T11:51:29.199416Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.185683Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:43.607329Z", + "start_time": "2026-04-01T10:19:43.563753Z" + } + }, + "source": [ + "x_pts1 = linopy.breakpoints([0, 30, 60, 100])\n", + "y_pts1 = linopy.breakpoints([0, 36, 84, 170])\n", + "print(\"x_pts:\", x_pts1.values)\n", + "print(\"y_pts:\", y_pts1.values)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.200170Z", + "iopub.status.busy": "2026-03-06T11:51:29.200087Z", + "iopub.status.idle": "2026-03-06T11:51:29.266847Z", + "shell.execute_reply": "2026-03-06T11:51:29.266379Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.200161Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:43.655062Z", + "start_time": "2026-04-01T10:19:43.614598Z" + } + }, + "source": [ + "m", + "1", + " ", + "=", + " ", + "l", + "i", + "n", + "o", + "p", + "y", + ".", + "M", + "o", + "d", + "e", + "l", + "(", + ")", + "\n", + "\n", + "p", + "o", + "w", + "e", + "r", + " ", + "=", + " ", + "m", + "1", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "p", + "o", + "w", + "e", + "r", + "\"", + ",", + " ", + "l", + "o", + "w", + "e", + "r", + "=", + "0", + ",", + " ", + "u", + "p", + "p", + "e", + "r", + "=", + "1", + "0", + "0", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "f", + "u", + "e", + "l", + " ", + "=", + " ", + "m", + "1", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "f", + "u", + "e", + "l", + "\"", + ",", + " ", + "l", + "o", + "w", + "e", + "r", + "=", + "0", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "\n", + "#", + " ", + "b", + "r", + "e", + "a", + "k", + "p", + "o", + "i", + "n", + "t", + "s", + " ", + "a", + "r", + "e", + " ", + "a", + "u", + "t", + "o", + "-", + "b", + "r", + "o", + "a", + "d", + "c", + "a", + "s", + "t", + " ", + "t", + "o", + " ", + "m", + "a", + "t", + "c", + "h", + " ", + "t", + "h", + "e", + " ", + "t", + "i", + "m", + "e", + " ", + "d", + "i", + "m", + "e", + "n", + "s", + "i", + "o", + "n", + "\n", + "m", + "1", + ".", + "a", + "d", + "d", + "_", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + "\n", + " ", + " ", + " ", + " ", + "(", + "p", + "o", + "w", + "e", + "r", + ",", + " ", + "x", + "_", + "p", + "t", + "s", + "1", + ")", + ",", + "\n", + " ", + " ", + " ", + " ", + "(", + "f", + "u", + "e", + "l", + ",", + " ", + "y", + "_", + "p", + "t", + "s", + "1", + ")", + ",", + "\n", + " ", + " ", + " ", + " ", + "n", + "a", + "m", + "e", + "=", + "\"", + "p", + "w", + "l", + "\"", + ",", + "\n", + " ", + " ", + " ", + " ", + "m", + "e", + "t", + "h", + "o", + "d", + "=", + "\"", + "s", + "o", + "s", + "2", + "\"", + ",", + "\n", + ")", + "\n", + "\n", + "d", + "e", + "m", + "a", + "n", + "d", + "1", + " ", + "=", + " ", + "x", + "r", + ".", + "D", + "a", + "t", + "a", + "A", + "r", + "r", + "a", + "y", + "(", + "[", + "5", + "0", + ",", + " ", + "8", + "0", + ",", + " ", + "3", + "0", + "]", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "m", + "1", + ".", + "a", + "d", + "d", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + "p", + "o", + "w", + "e", + "r", + " ", + ">", + "=", + " ", + "d", + "e", + "m", + "a", + "n", + "d", + "1", + ",", + " ", + "n", + "a", + "m", + "e", + "=", + "\"", + "d", + "e", + "m", + "a", + "n", + "d", + "\"", + ")", + "\n", + "m", + "1", + ".", + "a", + "d", + "d", + "_", + "o", + "b", + "j", + "e", + "c", + "t", + "i", + "v", + "e", + "(", + "f", + "u", + "e", + "l", + ".", + "s", + "u", + "m", + "(", + ")", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.267522Z", + "iopub.status.busy": "2026-03-06T11:51:29.267433Z", + "iopub.status.idle": "2026-03-06T11:51:29.326758Z", + "shell.execute_reply": "2026-03-06T11:51:29.326518Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.267514Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:43.708234Z", + "start_time": "2026-04-01T10:19:43.657664Z" + } + }, + "source": [ + "m1.solve(reformulate_sos=\"auto\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.327139Z", + "iopub.status.busy": "2026-03-06T11:51:29.327044Z", + "iopub.status.idle": "2026-03-06T11:51:29.339334Z", + "shell.execute_reply": "2026-03-06T11:51:29.338974Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.327130Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:43.720685Z", + "start_time": "2026-04-01T10:19:43.714174Z" + } + }, + "source": [ + "m1.solution[[\"power\", \"fuel\"]].to_pandas()" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.339689Z", + "iopub.status.busy": "2026-03-06T11:51:29.339608Z", + "iopub.status.idle": "2026-03-06T11:51:29.489677Z", + "shell.execute_reply": "2026-03-06T11:51:29.489280Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.339680Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:43.909787Z", + "start_time": "2026-04-01T10:19:43.740759Z" + } + }, + "source": [ + "bp1 = linopy.breakpoints({\"power\": x_pts1.values, \"fuel\": y_pts1.values}, dim=\"var\")\n", + "plot_pwl_results(m1, bp1, demand1, color=\"C0\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Incremental formulation — 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." + ] + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.490092Z", + "iopub.status.busy": "2026-03-06T11:51:29.490011Z", + "iopub.status.idle": "2026-03-06T11:51:29.500894Z", + "shell.execute_reply": "2026-03-06T11:51:29.500558Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.490084Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:43.926001Z", + "start_time": "2026-04-01T10:19:43.921143Z" + } + }, + "source": [ + "x_pts2 = linopy.breakpoints([0, 50, 100, 150])\n", + "y_pts2 = linopy.breakpoints([0, 55, 130, 225])\n", + "print(\"x_pts:\", x_pts2.values)\n", + "print(\"y_pts:\", y_pts2.values)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.501317Z", + "iopub.status.busy": "2026-03-06T11:51:29.501216Z", + "iopub.status.idle": "2026-03-06T11:51:29.604024Z", + "shell.execute_reply": "2026-03-06T11:51:29.603543Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.501307Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:44.020850Z", + "start_time": "2026-04-01T10:19:43.930951Z" + } + }, + "source": [ + "m", + "2", + " ", + "=", + " ", + "l", + "i", + "n", + "o", + "p", + "y", + ".", + "M", + "o", + "d", + "e", + "l", + "(", + ")", + "\n", + "\n", + "p", + "o", + "w", + "e", + "r", + " ", + "=", + " ", + "m", + "2", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "p", + "o", + "w", + "e", + "r", + "\"", + ",", + " ", + "l", + "o", + "w", + "e", + "r", + "=", + "0", + ",", + " ", + "u", + "p", + "p", + "e", + "r", + "=", + "1", + "5", + "0", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "f", + "u", + "e", + "l", + " ", + "=", + " ", + "m", + "2", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "f", + "u", + "e", + "l", + "\"", + ",", + " ", + "l", + "o", + "w", + "e", + "r", + "=", + "0", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "\n", + "m", + "2", + ".", + "a", + "d", + "d", + "_", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + "\n", + " ", + " ", + " ", + " ", + "(", + "p", + "o", + "w", + "e", + "r", + ",", + " ", + "x", + "_", + "p", + "t", + "s", + "2", + ")", + ",", + "\n", + " ", + " ", + " ", + " ", + "(", + "f", + "u", + "e", + "l", + ",", + " ", + "y", + "_", + "p", + "t", + "s", + "2", + ")", + ",", + "\n", + " ", + " ", + " ", + " ", + "n", + "a", + "m", + "e", + "=", + "\"", + "p", + "w", + "l", + "\"", + ",", + "\n", + " ", + " ", + " ", + " ", + "m", + "e", + "t", + "h", + "o", + "d", + "=", + "\"", + "i", + "n", + "c", + "r", + "e", + "m", + "e", + "n", + "t", + "a", + "l", + "\"", + ",", + "\n", + ")", + "\n", + "\n", + "d", + "e", + "m", + "a", + "n", + "d", + "2", + " ", + "=", + " ", + "x", + "r", + ".", + "D", + "a", + "t", + "a", + "A", + "r", + "r", + "a", + "y", + "(", + "[", + "8", + "0", + ",", + " ", + "1", + "2", + "0", + ",", + " ", + "5", + "0", + "]", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "m", + "2", + ".", + "a", + "d", + "d", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + "p", + "o", + "w", + "e", + "r", + " ", + ">", + "=", + " ", + "d", + "e", + "m", + "a", + "n", + "d", + "2", + ",", + " ", + "n", + "a", + "m", + "e", + "=", + "\"", + "d", + "e", + "m", + "a", + "n", + "d", + "\"", + ")", + "\n", + "m", + "2", + ".", + "a", + "d", + "d", + "_", + "o", + "b", + "j", + "e", + "c", + "t", + "i", + "v", + "e", + "(", + "f", + "u", + "e", + "l", + ".", + "s", + "u", + "m", + "(", + ")", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.604434Z", + "iopub.status.busy": "2026-03-06T11:51:29.604359Z", + "iopub.status.idle": "2026-03-06T11:51:29.680947Z", + "shell.execute_reply": "2026-03-06T11:51:29.680667Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.604427Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:44.084629Z", + "start_time": "2026-04-01T10:19:44.026059Z" + } + }, + "source": [ + "m2.solve(reformulate_sos=\"auto\");" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.681833Z", + "iopub.status.busy": "2026-03-06T11:51:29.681725Z", + "iopub.status.idle": "2026-03-06T11:51:29.698558Z", + "shell.execute_reply": "2026-03-06T11:51:29.698011Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.681822Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:44.093236Z", + "start_time": "2026-04-01T10:19:44.088898Z" + } + }, + "source": [ + "m2.solution[[\"power\", \"fuel\"]].to_pandas()" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.699350Z", + "iopub.status.busy": "2026-03-06T11:51:29.699116Z", + "iopub.status.idle": "2026-03-06T11:51:29.852000Z", + "shell.execute_reply": "2026-03-06T11:51:29.851741Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.699334Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:44.182075Z", + "start_time": "2026-04-01T10:19:44.103633Z" + } + }, + "source": [ + "bp2 = linopy.breakpoints({\"power\": x_pts2.values, \"fuel\": y_pts2.values}, dim=\"var\")\n", + "plot_pwl_results(m2, bp2, demand2, color=\"C1\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Disjunctive formulation — 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", + "**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", + "\n", + "The disjunctive formulation is selected automatically when the breakpoint\n", + "arrays have a segment dimension (created by `linopy.segments()`)." + ] + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.852397Z", + "iopub.status.busy": "2026-03-06T11:51:29.852305Z", + "iopub.status.idle": "2026-03-06T11:51:29.866500Z", + "shell.execute_reply": "2026-03-06T11:51:29.866141Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.852387Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:44.195935Z", + "start_time": "2026-04-01T10:19:44.191939Z" + } + }, + "source": [ + "# x-breakpoints define where each segment lives on the power axis\n", + "# y-breakpoints define the corresponding cost values\n", + "x_seg = linopy.segments([(0, 0), (50, 80)])\n", + "y_seg = linopy.segments([(0, 0), (125, 200)])\n", + "print(\"x segments:\\n\", x_seg.to_pandas())\n", + "print(\"y segments:\\n\", y_seg.to_pandas())" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.866940Z", + "iopub.status.busy": "2026-03-06T11:51:29.866839Z", + "iopub.status.idle": "2026-03-06T11:51:29.955272Z", + "shell.execute_reply": "2026-03-06T11:51:29.954810Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.866931Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:44.261526Z", + "start_time": "2026-04-01T10:19:44.204505Z" + } + }, + "source": [ + "m", + "3", + " ", + "=", + " ", + "l", + "i", + "n", + "o", + "p", + "y", + ".", + "M", + "o", + "d", + "e", + "l", + "(", + ")", + "\n", + "\n", + "p", + "o", + "w", + "e", + "r", + " ", + "=", + " ", + "m", + "3", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "p", + "o", + "w", + "e", + "r", + "\"", + ",", + " ", + "l", + "o", + "w", + "e", + "r", + "=", + "0", + ",", + " ", + "u", + "p", + "p", + "e", + "r", + "=", + "8", + "0", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "c", + "o", + "s", + "t", + " ", + "=", + " ", + "m", + "3", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "c", + "o", + "s", + "t", + "\"", + ",", + " ", + "l", + "o", + "w", + "e", + "r", + "=", + "0", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "b", + "a", + "c", + "k", + "u", + "p", + " ", + "=", + " ", + "m", + "3", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "b", + "a", + "c", + "k", + "u", + "p", + "\"", + ",", + " ", + "l", + "o", + "w", + "e", + "r", + "=", + "0", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "\n", + "m", + "3", + ".", + "a", + "d", + "d", + "_", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + "\n", + " ", + " ", + " ", + " ", + "(", + "p", + "o", + "w", + "e", + "r", + ",", + " ", + "x", + "_", + "s", + "e", + "g", + ")", + ",", + "\n", + " ", + " ", + " ", + " ", + "(", + "c", + "o", + "s", + "t", + ",", + " ", + "y", + "_", + "s", + "e", + "g", + ")", + ",", + "\n", + " ", + " ", + " ", + " ", + "n", + "a", + "m", + "e", + "=", + "\"", + "p", + "w", + "l", + "\"", + ",", + "\n", + ")", + "\n", + "\n", + "d", + "e", + "m", + "a", + "n", + "d", + "3", + " ", + "=", + " ", + "x", + "r", + ".", + "D", + "a", + "t", + "a", + "A", + "r", + "r", + "a", + "y", + "(", + "[", + "1", + "0", + ",", + " ", + "7", + "0", + ",", + " ", + "9", + "0", + "]", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "m", + "3", + ".", + "a", + "d", + "d", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + "p", + "o", + "w", + "e", + "r", + " ", + "+", + " ", + "b", + "a", + "c", + "k", + "u", + "p", + " ", + ">", + "=", + " ", + "d", + "e", + "m", + "a", + "n", + "d", + "3", + ",", + " ", + "n", + "a", + "m", + "e", + "=", + "\"", + "d", + "e", + "m", + "a", + "n", + "d", + "\"", + ")", + "\n", + "m", + "3", + ".", + "a", + "d", + "d", + "_", + "o", + "b", + "j", + "e", + "c", + "t", + "i", + "v", + "e", + "(", + "(", + "c", + "o", + "s", + "t", + " ", + "+", + " ", + "1", + "0", + " ", + "*", + " ", + "b", + "a", + "c", + "k", + "u", + "p", + ")", + ".", + "s", + "u", + "m", + "(", + ")", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.955750Z", + "iopub.status.busy": "2026-03-06T11:51:29.955667Z", + "iopub.status.idle": "2026-03-06T11:51:30.027311Z", + "shell.execute_reply": "2026-03-06T11:51:30.026945Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.955741Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:44.323093Z", + "start_time": "2026-04-01T10:19:44.265474Z" + } + }, + "source": [ + "m3.solve(reformulate_sos=\"auto\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:30.028114Z", + "iopub.status.busy": "2026-03-06T11:51:30.027864Z", + "iopub.status.idle": "2026-03-06T11:51:30.043138Z", + "shell.execute_reply": "2026-03-06T11:51:30.042813Z", + "shell.execute_reply.started": "2026-03-06T11:51:30.028095Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:44.332013Z", + "start_time": "2026-04-01T10:19:44.326391Z" + } + }, + "source": [ + "m3.solution[[\"power\", \"cost\", \"backup\"]].to_pandas()" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#", + "#", + " ", + "4", + ".", + " ", + "T", + "a", + "n", + "g", + "e", + "n", + "t", + " ", + "l", + "i", + "n", + "e", + "s", + " ", + "—", + " ", + "C", + "o", + "n", + "c", + "a", + "v", + "e", + " ", + "e", + "f", + "f", + "i", + "c", + "i", + "e", + "n", + "c", + "y", + " ", + "b", + "o", + "u", + "n", + "d", + "\n", + "\n", + "W", + "h", + "e", + "n", + " ", + "t", + "h", + "e", + " ", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + " ", + "f", + "u", + "n", + "c", + "t", + "i", + "o", + "n", + " ", + "i", + "s", + " ", + "*", + "*", + "c", + "o", + "n", + "c", + "a", + "v", + "e", + "*", + "*", + " ", + "a", + "n", + "d", + " ", + "w", + "e", + " ", + "w", + "a", + "n", + "t", + " ", + "t", + "o", + " ", + "b", + "o", + "u", + "n", + "d", + " ", + "y", + " ", + "*", + "*", + "a", + "b", + "o", + "v", + "e", + "*", + "*", + "\n", + "(", + "i", + ".", + "e", + ".", + " ", + "`", + "y", + " ", + "<", + "=", + " ", + "f", + "(", + "x", + ")", + "`", + ")", + ",", + " ", + "w", + "e", + " ", + "c", + "a", + "n", + " ", + "u", + "s", + "e", + " ", + "`", + "t", + "a", + "n", + "g", + "e", + "n", + "t", + "_", + "l", + "i", + "n", + "e", + "s", + "`", + " ", + "t", + "o", + " ", + "g", + "e", + "t", + " ", + "p", + "e", + "r", + "-", + "s", + "e", + "g", + "m", + "e", + "n", + "t", + " ", + "l", + "i", + "n", + "e", + "a", + "r", + "\n", + "e", + "x", + "p", + "r", + "e", + "s", + "s", + "i", + "o", + "n", + "s", + " ", + "a", + "n", + "d", + " ", + "a", + "d", + "d", + " ", + "t", + "h", + "e", + "m", + " ", + "a", + "s", + " ", + "r", + "e", + "g", + "u", + "l", + "a", + "r", + " ", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + " ", + "—", + " ", + "n", + "o", + " ", + "S", + "O", + "S", + "2", + " ", + "o", + "r", + " ", + "b", + "i", + "n", + "a", + "r", + "y", + "\n", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + " ", + "n", + "e", + "e", + "d", + "e", + "d", + ".", + " ", + "T", + "h", + "i", + "s", + " ", + "i", + "s", + " ", + "t", + "h", + "e", + " ", + "f", + "a", + "s", + "t", + "e", + "s", + "t", + " ", + "t", + "o", + " ", + "s", + "o", + "l", + "v", + "e", + ".", + "\n", + "\n", + "H", + "e", + "r", + "e", + " ", + "w", + "e", + " ", + "b", + "o", + "u", + "n", + "d", + " ", + "f", + "u", + "e", + "l", + " ", + "c", + "o", + "n", + "s", + "u", + "m", + "p", + "t", + "i", + "o", + "n", + " ", + "*", + "b", + "e", + "l", + "o", + "w", + "*", + " ", + "a", + " ", + "c", + "o", + "n", + "c", + "a", + "v", + "e", + " ", + "e", + "f", + "f", + "i", + "c", + "i", + "e", + "n", + "c", + "y", + " ", + "c", + "u", + "r", + "v", + "e", + "." + ] + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:30.043492Z", + "iopub.status.busy": "2026-03-06T11:51:30.043410Z", + "iopub.status.idle": "2026-03-06T11:51:30.113382Z", + "shell.execute_reply": "2026-03-06T11:51:30.112320Z", + "shell.execute_reply.started": "2026-03-06T11:51:30.043484Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:44.365878Z", + "start_time": "2026-04-01T10:19:44.342206Z" + } + }, + "source": [ + "x", + "_", + "p", + "t", + "s", + "4", + " ", + "=", + " ", + "l", + "i", + "n", + "o", + "p", + "y", + ".", + "b", + "r", + "e", + "a", + "k", + "p", + "o", + "i", + "n", + "t", + "s", + "(", + "[", + "0", + ",", + " ", + "4", + "0", + ",", + " ", + "8", + "0", + ",", + " ", + "1", + "2", + "0", + "]", + ")", + "\n", + "#", + " ", + "C", + "o", + "n", + "c", + "a", + "v", + "e", + " ", + "c", + "u", + "r", + "v", + "e", + ":", + " ", + "d", + "e", + "c", + "r", + "e", + "a", + "s", + "i", + "n", + "g", + " ", + "m", + "a", + "r", + "g", + "i", + "n", + "a", + "l", + " ", + "f", + "u", + "e", + "l", + " ", + "p", + "e", + "r", + " ", + "M", + "W", + "\n", + "y", + "_", + "p", + "t", + "s", + "4", + " ", + "=", + " ", + "l", + "i", + "n", + "o", + "p", + "y", + ".", + "b", + "r", + "e", + "a", + "k", + "p", + "o", + "i", + "n", + "t", + "s", + "(", + "[", + "0", + ",", + " ", + "5", + "0", + ",", + " ", + "9", + "0", + ",", + " ", + "1", + "2", + "0", + "]", + ")", + "\n", + "\n", + "m", + "4", + " ", + "=", + " ", + "l", + "i", + "n", + "o", + "p", + "y", + ".", + "M", + "o", + "d", + "e", + "l", + "(", + ")", + "\n", + "\n", + "p", + "o", + "w", + "e", + "r", + " ", + "=", + " ", + "m", + "4", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "p", + "o", + "w", + "e", + "r", + "\"", + ",", + " ", + "l", + "o", + "w", + "e", + "r", + "=", + "0", + ",", + " ", + "u", + "p", + "p", + "e", + "r", + "=", + "1", + "2", + "0", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "f", + "u", + "e", + "l", + " ", + "=", + " ", + "m", + "4", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "f", + "u", + "e", + "l", + "\"", + ",", + " ", + "l", + "o", + "w", + "e", + "r", + "=", + "0", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "\n", + "#", + " ", + "t", + "a", + "n", + "g", + "e", + "n", + "t", + "_", + "l", + "i", + "n", + "e", + "s", + " ", + "r", + "e", + "t", + "u", + "r", + "n", + "s", + " ", + "o", + "n", + "e", + " ", + "L", + "i", + "n", + "e", + "a", + "r", + "E", + "x", + "p", + "r", + "e", + "s", + "s", + "i", + "o", + "n", + " ", + "p", + "e", + "r", + " ", + "s", + "e", + "g", + "m", + "e", + "n", + "t", + " ", + "—", + " ", + "p", + "u", + "r", + "e", + " ", + "L", + "P", + ",", + " ", + "n", + "o", + " ", + "a", + "u", + "x", + " ", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "\n", + "t", + " ", + "=", + " ", + "l", + "i", + "n", + "o", + "p", + "y", + ".", + "t", + "a", + "n", + "g", + "e", + "n", + "t", + "_", + "l", + "i", + "n", + "e", + "s", + "(", + "p", + "o", + "w", + "e", + "r", + ",", + " ", + "x", + "_", + "p", + "t", + "s", + "4", + ",", + " ", + "y", + "_", + "p", + "t", + "s", + "4", + ")", + "\n", + "m", + "4", + ".", + "a", + "d", + "d", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + "f", + "u", + "e", + "l", + " ", + "<", + "=", + " ", + "t", + ",", + " ", + "n", + "a", + "m", + "e", + "=", + "\"", + "p", + "w", + "l", + "\"", + ")", + "\n", + "\n", + "d", + "e", + "m", + "a", + "n", + "d", + "4", + " ", + "=", + " ", + "x", + "r", + ".", + "D", + "a", + "t", + "a", + "A", + "r", + "r", + "a", + "y", + "(", + "[", + "3", + "0", + ",", + " ", + "8", + "0", + ",", + " ", + "1", + "0", + "0", + "]", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "m", + "4", + ".", + "a", + "d", + "d", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + "p", + "o", + "w", + "e", + "r", + " ", + "=", + "=", + " ", + "d", + "e", + "m", + "a", + "n", + "d", + "4", + ",", + " ", + "n", + "a", + "m", + "e", + "=", + "\"", + "d", + "e", + "m", + "a", + "n", + "d", + "\"", + ")", + "\n", + "#", + " ", + "M", + "a", + "x", + "i", + "m", + "i", + "z", + "e", + " ", + "f", + "u", + "e", + "l", + " ", + "(", + "t", + "o", + " ", + "p", + "u", + "s", + "h", + " ", + "a", + "g", + "a", + "i", + "n", + "s", + "t", + " ", + "t", + "h", + "e", + " ", + "u", + "p", + "p", + "e", + "r", + " ", + "b", + "o", + "u", + "n", + "d", + ")", + "\n", + "m", + "4", + ".", + "a", + "d", + "d", + "_", + "o", + "b", + "j", + "e", + "c", + "t", + "i", + "v", + "e", + "(", + "-", + "f", + "u", + "e", + "l", + ".", + "s", + "u", + "m", + "(", + ")", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:30.113818Z", + "iopub.status.busy": "2026-03-06T11:51:30.113727Z", + "iopub.status.idle": "2026-03-06T11:51:30.171329Z", + "shell.execute_reply": "2026-03-06T11:51:30.170942Z", + "shell.execute_reply.started": "2026-03-06T11:51:30.113810Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:44.397264Z", + "start_time": "2026-04-01T10:19:44.369422Z" + } + }, + "source": [ + "m4.solve(reformulate_sos=\"auto\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:30.172009Z", + "iopub.status.busy": "2026-03-06T11:51:30.171791Z", + "iopub.status.idle": "2026-03-06T11:51:30.191956Z", + "shell.execute_reply": "2026-03-06T11:51:30.191556Z", + "shell.execute_reply.started": "2026-03-06T11:51:30.171993Z" + }, "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.424283Z", - "start_time": "2026-04-01T07:35:36.419372Z" + "end_time": "2026-04-01T10:19:44.412092Z", + "start_time": "2026-04-01T10:19:44.407213Z" + } + }, + "source": [ + "m4.solution[[\"power\", \"fuel\"]].to_pandas()" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-03-06T11:51:30.192604Z", + "iopub.status.busy": "2026-03-06T11:51:30.192376Z", + "iopub.status.idle": "2026-03-06T11:51:30.345074Z", + "shell.execute_reply": "2026-03-06T11:51:30.344642Z", + "shell.execute_reply.started": "2026-03-06T11:51:30.192590Z" }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:44.525217Z", + "start_time": "2026-04-01T10:19:44.418513Z" + } + }, + "source": [ + "bp4 = linopy.breakpoints({\"power\": x_pts4.values, \"fuel\": y_pts4.values}, dim=\"var\")\n", + "plot_pwl_results(m4, bp4, demand4, color=\"C4\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Slopes mode — 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", + "slopes, x-coordinates, and an initial y-value." + ] + }, + { + "cell_type": "code", + "metadata": { "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.327139Z", - "iopub.status.busy": "2026-03-06T11:51:29.327044Z", - "iopub.status.idle": "2026-03-06T11:51:29.339334Z", - "shell.execute_reply": "2026-03-06T11:51:29.338974Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.327130Z" + "iopub.execute_input": "2026-03-06T11:51:30.345523Z", + "iopub.status.busy": "2026-03-06T11:51:30.345404Z", + "iopub.status.idle": "2026-03-06T11:51:30.357312Z", + "shell.execute_reply": "2026-03-06T11:51:30.356954Z", + "shell.execute_reply.started": "2026-03-06T11:51:30.345513Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T10:19:44.558053Z", + "start_time": "2026-04-01T10:19:44.552275Z" } }, + "source": [ + "# Marginal costs: $1.1/MW for 0-50, $1.5/MW for 50-100, $1.9/MW for 100-150\n", + "x_pts5 = linopy.breakpoints([0, 50, 100, 150])\n", + "y_pts5 = linopy.breakpoints(slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0)\n", + "print(\"y breakpoints from slopes:\", y_pts5.values)" + ], "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, "source": [ - "m1.solution[[\"power\", \"fuel\"]].to_pandas()" + "#", + "#", + " ", + "6", + ".", + " ", + "A", + "c", + "t", + "i", + "v", + "e", + " ", + "p", + "a", + "r", + "a", + "m", + "e", + "t", + "e", + "r", + " ", + "-", + "-", + " ", + "U", + "n", + "i", + "t", + " ", + "c", + "o", + "m", + "m", + "i", + "t", + "m", + "e", + "n", + "t", + " ", + "w", + "i", + "t", + "h", + " ", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + " ", + "e", + "f", + "f", + "i", + "c", + "i", + "e", + "n", + "c", + "y", + "\n", + "\n", + "I", + "n", + " ", + "u", + "n", + "i", + "t", + " ", + "c", + "o", + "m", + "m", + "i", + "t", + "m", + "e", + "n", + "t", + " ", + "p", + "r", + "o", + "b", + "l", + "e", + "m", + "s", + ",", + " ", + "a", + " ", + "b", + "i", + "n", + "a", + "r", + "y", + " ", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + " ", + "$", + "u", + "_", + "t", + "$", + " ", + "c", + "o", + "n", + "t", + "r", + "o", + "l", + "s", + " ", + "w", + "h", + "e", + "t", + "h", + "e", + "r", + " ", + "a", + "\n", + "u", + "n", + "i", + "t", + " ", + "i", + "s", + " ", + "*", + "*", + "o", + "n", + "*", + "*", + " ", + "o", + "r", + " ", + "*", + "*", + "o", + "f", + "f", + "*", + "*", + ".", + " ", + "W", + "h", + "e", + "n", + " ", + "o", + "f", + "f", + ",", + " ", + "b", + "o", + "t", + "h", + " ", + "p", + "o", + "w", + "e", + "r", + " ", + "o", + "u", + "t", + "p", + "u", + "t", + " ", + "a", + "n", + "d", + " ", + "f", + "u", + "e", + "l", + " ", + "c", + "o", + "n", + "s", + "u", + "m", + "p", + "t", + "i", + "o", + "n", + "\n", + "m", + "u", + "s", + "t", + " ", + "b", + "e", + " ", + "z", + "e", + "r", + "o", + ".", + " ", + "W", + "h", + "e", + "n", + " ", + "o", + "n", + ",", + " ", + "t", + "h", + "e", + " ", + "u", + "n", + "i", + "t", + " ", + "o", + "p", + "e", + "r", + "a", + "t", + "e", + "s", + " ", + "w", + "i", + "t", + "h", + "i", + "n", + " ", + "i", + "t", + "s", + " ", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "-", + "l", + "i", + "n", + "e", + "a", + "r", + "\n", + "e", + "f", + "f", + "i", + "c", + "i", + "e", + "n", + "c", + "y", + " ", + "c", + "u", + "r", + "v", + "e", + " ", + "b", + "e", + "t", + "w", + "e", + "e", + "n", + " ", + "$", + "P", + "_", + "{", + "m", + "i", + "n", + "}", + "$", + " ", + "a", + "n", + "d", + " ", + "$", + "P", + "_", + "{", + "m", + "a", + "x", + "}", + "$", + ".", + "\n", + "\n", + "T", + "h", + "e", + " ", + "`", + "a", + "c", + "t", + "i", + "v", + "e", + "`", + " ", + "k", + "e", + "y", + "w", + "o", + "r", + "d", + " ", + "o", + "n", + " ", + "`", + "a", + "d", + "d", + "_", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + ")", + "`", + " ", + "h", + "a", + "n", + "d", + "l", + "e", + "s", + " ", + "t", + "h", + "i", + "s", + " ", + "b", + "y", + "\n", + "g", + "a", + "t", + "i", + "n", + "g", + " ", + "t", + "h", + "e", + " ", + "i", + "n", + "t", + "e", + "r", + "n", + "a", + "l", + " ", + "P", + "W", + "L", + " ", + "f", + "o", + "r", + "m", + "u", + "l", + "a", + "t", + "i", + "o", + "n", + " ", + "w", + "i", + "t", + "h", + " ", + "t", + "h", + "e", + " ", + "c", + "o", + "m", + "m", + "i", + "t", + "m", + "e", + "n", + "t", + " ", + "b", + "i", + "n", + "a", + "r", + "y", + ":", + "\n", + "\n", + "-", + " ", + "*", + "*", + "I", + "n", + "c", + "r", + "e", + "m", + "e", + "n", + "t", + "a", + "l", + ":", + "*", + "*", + " ", + "d", + "e", + "l", + "t", + "a", + " ", + "b", + "o", + "u", + "n", + "d", + "s", + " ", + "t", + "i", + "g", + "h", + "t", + "e", + "n", + " ", + "f", + "r", + "o", + "m", + " ", + "$", + "\\", + "d", + "e", + "l", + "t", + "a", + "_", + "i", + " ", + "\\", + "l", + "e", + "q", + " ", + "1", + "$", + " ", + "t", + "o", + "\n", + " ", + " ", + "$", + "\\", + "d", + "e", + "l", + "t", + "a", + "_", + "i", + " ", + "\\", + "l", + "e", + "q", + " ", + "u", + "$", + ",", + " ", + "a", + "n", + "d", + " ", + "b", + "a", + "s", + "e", + " ", + "t", + "e", + "r", + "m", + "s", + " ", + "a", + "r", + "e", + " ", + "m", + "u", + "l", + "t", + "i", + "p", + "l", + "i", + "e", + "d", + " ", + "b", + "y", + " ", + "$", + "u", + "$", + "\n", + "-", + " ", + "*", + "*", + "S", + "O", + "S", + "2", + ":", + "*", + "*", + " ", + "c", + "o", + "n", + "v", + "e", + "x", + "i", + "t", + "y", + " ", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + " ", + "b", + "e", + "c", + "o", + "m", + "e", + "s", + " ", + "$", + "\\", + "s", + "u", + "m", + " ", + "\\", + "l", + "a", + "m", + "b", + "d", + "a", + "_", + "i", + " ", + "=", + " ", + "u", + "$", + "\n", + "-", + " ", + "*", + "*", + "D", + "i", + "s", + "j", + "u", + "n", + "c", + "t", + "i", + "v", + "e", + ":", + "*", + "*", + " ", + "s", + "e", + "g", + "m", + "e", + "n", + "t", + " ", + "s", + "e", + "l", + "e", + "c", + "t", + "i", + "o", + "n", + " ", + "b", + "e", + "c", + "o", + "m", + "e", + "s", + " ", + "$", + "\\", + "s", + "u", + "m", + " ", + "z", + "_", + "k", + " ", + "=", + " ", + "u", + "$", + "\n", + "\n", + "T", + "h", + "i", + "s", + " ", + "i", + "s", + " ", + "t", + "h", + "e", + " ", + "o", + "n", + "l", + "y", + " ", + "g", + "a", + "t", + "i", + "n", + "g", + " ", + "b", + "e", + "h", + "a", + "v", + "i", + "o", + "r", + " ", + "e", + "x", + "p", + "r", + "e", + "s", + "s", + "i", + "b", + "l", + "e", + " ", + "w", + "i", + "t", + "h", + " ", + "p", + "u", + "r", + "e", + " ", + "l", + "i", + "n", + "e", + "a", + "r", + " ", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + ".", + "\n", + "S", + "e", + "l", + "e", + "c", + "t", + "i", + "v", + "e", + "l", + "y", + " ", + "*", + "r", + "e", + "l", + "a", + "x", + "i", + "n", + "g", + "*", + " ", + "t", + "h", + "e", + " ", + "P", + "W", + "L", + " ", + "(", + "l", + "e", + "t", + "t", + "i", + "n", + "g", + " ", + "x", + ",", + " ", + "y", + " ", + "f", + "l", + "o", + "a", + "t", + " ", + "f", + "r", + "e", + "e", + "l", + "y", + " ", + "w", + "h", + "e", + "n", + " ", + "o", + "f", + "f", + ")", + " ", + "w", + "o", + "u", + "l", + "d", + "\n", + "r", + "e", + "q", + "u", + "i", + "r", + "e", + " ", + "b", + "i", + "g", + "-", + "M", + " ", + "o", + "r", + " ", + "i", + "n", + "d", + "i", + "c", + "a", + "t", + "o", + "r", + " ", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "." ] }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.525484Z", - "start_time": "2026-04-01T07:35:36.436334Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.339689Z", - "iopub.status.busy": "2026-03-06T11:51:29.339608Z", - "iopub.status.idle": "2026-03-06T11:51:29.489677Z", - "shell.execute_reply": "2026-03-06T11:51:29.489280Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.339680Z" + "end_time": "2026-04-01T10:19:44.582188Z", + "start_time": "2026-04-01T10:19:44.576288Z" } }, - "outputs": [], "source": [ - "bp1 = xr.concat([x_pts1, y_pts1], dim=pd.Index([\"power\", \"fuel\"], name=\"var\"))\n", - "plot_pwl_results(m1, bp1, demand1, color=\"C0\")" - ] + "# Unit parameters: operates between 30-100 MW when on\n", + "p_min, p_max = 30, 100\n", + "fuel_min, fuel_max = 40, 170\n", + "startup_cost = 50\n", + "\n", + "x_pts6 = linopy.breakpoints([p_min, 60, p_max])\n", + "y_pts6 = linopy.breakpoints([fuel_min, 90, fuel_max])\n", + "print(\"Power breakpoints:\", x_pts6.values)\n", + "print(\"Fuel breakpoints: \", y_pts6.values)" + ], + "outputs": [], + "execution_count": null }, { - "cell_type": "markdown", - "metadata": {}, + "cell_type": "code", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T10:19:44.733423Z", + "start_time": "2026-04-01T10:19:44.620485Z" + } + }, "source": [ - "## 2. Incremental formulation — Coal plant\n", + "m", + "6", + " ", + "=", + " ", + "l", + "i", + "n", + "o", + "p", + "y", + ".", + "M", + "o", + "d", + "e", + "l", + "(", + ")", + "\n", + "\n", + "p", + "o", + "w", + "e", + "r", + " ", + "=", + " ", + "m", + "6", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "p", + "o", + "w", + "e", + "r", + "\"", + ",", + " ", + "l", + "o", + "w", + "e", + "r", + "=", + "0", + ",", + " ", + "u", + "p", + "p", + "e", + "r", + "=", + "p", + "_", + "m", + "a", + "x", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "f", + "u", + "e", + "l", + " ", + "=", + " ", + "m", + "6", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "f", + "u", + "e", + "l", + "\"", + ",", + " ", + "l", + "o", + "w", + "e", + "r", + "=", + "0", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "c", + "o", + "m", + "m", + "i", + "t", + " ", + "=", + " ", + "m", + "6", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "c", + "o", + "m", + "m", + "i", + "t", + "\"", + ",", + " ", + "b", + "i", + "n", + "a", + "r", + "y", + "=", + "T", + "r", + "u", + "e", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "\n", + "#", + " ", + "T", + "h", + "e", + " ", + "a", + "c", + "t", + "i", + "v", + "e", + " ", + "p", + "a", + "r", + "a", + "m", + "e", + "t", + "e", + "r", + " ", + "g", + "a", + "t", + "e", + "s", + " ", + "t", + "h", + "e", + " ", + "P", + "W", + "L", + " ", + "w", + "i", + "t", + "h", + " ", + "t", + "h", + "e", + " ", + "c", + "o", + "m", + "m", + "i", + "t", + "m", + "e", + "n", + "t", + " ", + "b", + "i", + "n", + "a", + "r", + "y", + ":", + "\n", + "#", + " ", + "-", + " ", + "c", + "o", + "m", + "m", + "i", + "t", + "=", + "1", + ":", + " ", + "p", + "o", + "w", + "e", + "r", + " ", + "i", + "n", + " ", + "[", + "3", + "0", + ",", + " ", + "1", + "0", + "0", + "]", + ",", + " ", + "f", + "u", + "e", + "l", + " ", + "=", + " ", + "f", + "(", + "p", + "o", + "w", + "e", + "r", + ")", + "\n", + "#", + " ", + "-", + " ", + "c", + "o", + "m", + "m", + "i", + "t", + "=", + "0", + ":", + " ", + "p", + "o", + "w", + "e", + "r", + " ", + "=", + " ", + "0", + ",", + " ", + "f", + "u", + "e", + "l", + " ", + "=", + " ", + "0", + "\n", + "m", + "6", + ".", + "a", + "d", + "d", + "_", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + "\n", + " ", + " ", + " ", + " ", + "(", + "p", + "o", + "w", + "e", + "r", + ",", + " ", + "x", + "_", + "p", + "t", + "s", + "6", + ")", + ",", + "\n", + " ", + " ", + " ", + " ", + "(", + "f", + "u", + "e", + "l", + ",", + " ", + "y", + "_", + "p", + "t", + "s", + "6", + ")", + ",", + "\n", + " ", + " ", + " ", + " ", + "a", + "c", + "t", + "i", + "v", + "e", + "=", + "c", + "o", + "m", + "m", + "i", + "t", + ",", + "\n", + " ", + " ", + " ", + " ", + "n", + "a", + "m", + "e", + "=", + "\"", + "p", + "w", + "l", + "\"", + ",", + "\n", + " ", + " ", + " ", + " ", + "m", + "e", + "t", + "h", + "o", + "d", + "=", + "\"", + "i", + "n", + "c", + "r", + "e", + "m", + "e", + "n", + "t", + "a", + "l", + "\"", + ",", "\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." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.531430Z", - "start_time": "2026-04-01T07:35:36.528406Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.490092Z", - "iopub.status.busy": "2026-03-06T11:51:29.490011Z", - "iopub.status.idle": "2026-03-06T11:51:29.500894Z", - "shell.execute_reply": "2026-03-06T11:51:29.500558Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.490084Z" - } - }, - "outputs": [], - "source": [ - "x_pts2 = linopy.breakpoints([0, 50, 100, 150])\n", - "y_pts2 = linopy.breakpoints([0, 55, 130, 225])\n", - "print(\"x_pts:\", x_pts2.values)\n", - "print(\"y_pts:\", y_pts2.values)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.605829Z", - "start_time": "2026-04-01T07:35:36.538213Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.501317Z", - "iopub.status.busy": "2026-03-06T11:51:29.501216Z", - "iopub.status.idle": "2026-03-06T11:51:29.604024Z", - "shell.execute_reply": "2026-03-06T11:51:29.603543Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.501307Z" - } - }, - "outputs": [], - "source": "m2 = linopy.Model()\n\npower = m2.add_variables(name=\"power\", lower=0, upper=150, coords=[time])\nfuel = m2.add_variables(name=\"fuel\", lower=0, coords=[time])\n\nm2.add_piecewise_constraints(\n (power, x_pts2),\n (fuel, y_pts2),\n name=\"pwl\",\n method=\"incremental\",\n)\n\ndemand2 = xr.DataArray([80, 120, 50], coords=[time])\nm2.add_constraints(power >= demand2, name=\"demand\")\nm2.add_objective(fuel.sum())" - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.661877Z", - "start_time": "2026-04-01T07:35:36.609352Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.604434Z", - "iopub.status.busy": "2026-03-06T11:51:29.604359Z", - "iopub.status.idle": "2026-03-06T11:51:29.680947Z", - "shell.execute_reply": "2026-03-06T11:51:29.680667Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.604427Z" - } - }, - "outputs": [], - "source": [ - "m2.solve(reformulate_sos=\"auto\");" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.674590Z", - "start_time": "2026-04-01T07:35:36.669960Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.681833Z", - "iopub.status.busy": "2026-03-06T11:51:29.681725Z", - "iopub.status.idle": "2026-03-06T11:51:29.698558Z", - "shell.execute_reply": "2026-03-06T11:51:29.698011Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.681822Z" - } - }, - "outputs": [], - "source": [ - "m2.solution[[\"power\", \"fuel\"]].to_pandas()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.766218Z", - "start_time": "2026-04-01T07:35:36.687140Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.699350Z", - "iopub.status.busy": "2026-03-06T11:51:29.699116Z", - "iopub.status.idle": "2026-03-06T11:51:29.852000Z", - "shell.execute_reply": "2026-03-06T11:51:29.851741Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.699334Z" - } - }, - "outputs": [], - "source": [ - "bp2 = xr.concat([x_pts2, y_pts2], dim=pd.Index([\"power\", \"fuel\"], name=\"var\"))\n", - "plot_pwl_results(m2, bp2, demand2, color=\"C1\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 3. Disjunctive formulation — Diesel generator\n", + ")", + "\n", + "\n", + "#", + " ", + "D", + "e", + "m", + "a", + "n", + "d", + ":", + " ", + "l", + "o", + "w", + " ", + "a", + "t", + " ", + "t", + "=", + "1", + " ", + "(", + "c", + "h", + "e", + "a", + "p", + "e", + "r", + " ", + "t", + "o", + " ", + "s", + "t", + "a", + "y", + " ", + "o", + "f", + "f", + ")", + ",", + " ", + "h", + "i", + "g", + "h", + " ", + "a", + "t", + " ", + "t", + "=", + "2", + ",", + "3", + "\n", + "d", + "e", + "m", + "a", + "n", + "d", + "6", + " ", + "=", + " ", + "x", + "r", + ".", + "D", + "a", + "t", + "a", + "A", + "r", + "r", + "a", + "y", + "(", + "[", + "1", + "5", + ",", + " ", + "7", + "0", + ",", + " ", + "5", + "0", + "]", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", + "\n", + "b", + "a", + "c", + "k", + "u", + "p", + " ", + "=", + " ", + "m", + "6", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "b", + "a", + "c", + "k", + "u", + "p", + "\"", + ",", + " ", + "l", + "o", + "w", + "e", + "r", + "=", + "0", + ",", + " ", + "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", + "t", + "i", + "m", + "e", + "]", + ")", "\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", - "**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", + "m", + "6", + ".", + "a", + "d", + "d", + "_", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", + "s", + "(", + "p", + "o", + "w", + "e", + "r", + " ", + "+", + " ", + "b", + "a", + "c", + "k", + "u", + "p", + " ", + ">", + "=", + " ", + "d", + "e", + "m", + "a", + "n", + "d", + "6", + ",", + " ", + "n", + "a", + "m", + "e", + "=", + "\"", + "d", + "e", + "m", + "a", + "n", + "d", + "\"", + ")", "\n", - "The disjunctive formulation is selected automatically when the breakpoint\n", - "arrays have a segment dimension (created by `linopy.segments()`)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.773687Z", - "start_time": "2026-04-01T07:35:36.769193Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.852397Z", - "iopub.status.busy": "2026-03-06T11:51:29.852305Z", - "iopub.status.idle": "2026-03-06T11:51:29.866500Z", - "shell.execute_reply": "2026-03-06T11:51:29.866141Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.852387Z" - } - }, - "outputs": [], - "source": [ - "# x-breakpoints define where each segment lives on the power axis\n", - "# y-breakpoints define the corresponding cost values\n", - "x_seg = linopy.segments([(0, 0), (50, 80)])\n", - "y_seg = linopy.segments([(0, 0), (125, 200)])\n", - "print(\"x segments:\\n\", x_seg.to_pandas())\n", - "print(\"y segments:\\n\", y_seg.to_pandas())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.862477Z", - "start_time": "2026-04-01T07:35:36.784561Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.866940Z", - "iopub.status.busy": "2026-03-06T11:51:29.866839Z", - "iopub.status.idle": "2026-03-06T11:51:29.955272Z", - "shell.execute_reply": "2026-03-06T11:51:29.954810Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.866931Z" - } - }, - "outputs": [], - "source": "m3 = linopy.Model()\n\npower = m3.add_variables(name=\"power\", lower=0, upper=80, coords=[time])\ncost = m3.add_variables(name=\"cost\", lower=0, coords=[time])\nbackup = m3.add_variables(name=\"backup\", lower=0, coords=[time])\n\nm3.add_piecewise_constraints(\n (power, x_seg),\n (cost, y_seg),\n name=\"pwl\",\n)\n\ndemand3 = xr.DataArray([10, 70, 90], coords=[time])\nm3.add_constraints(power + backup >= demand3, name=\"demand\")\nm3.add_objective((cost + 10 * backup).sum())" - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.925139Z", - "start_time": "2026-04-01T07:35:36.865201Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.955750Z", - "iopub.status.busy": "2026-03-06T11:51:29.955667Z", - "iopub.status.idle": "2026-03-06T11:51:30.027311Z", - "shell.execute_reply": "2026-03-06T11:51:30.026945Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.955741Z" - } - }, + "\n", + "#", + " ", + "O", + "b", + "j", + "e", + "c", + "t", + "i", + "v", + "e", + ":", + " ", + "f", + "u", + "e", + "l", + " ", + "+", + " ", + "s", + "t", + "a", + "r", + "t", + "u", + "p", + " ", + "c", + "o", + "s", + "t", + " ", + "+", + " ", + "b", + "a", + "c", + "k", + "u", + "p", + " ", + "a", + "t", + " ", + "$", + "5", + "/", + "M", + "W", + "\n", + "m", + "6", + ".", + "a", + "d", + "d", + "_", + "o", + "b", + "j", + "e", + "c", + "t", + "i", + "v", + "e", + "(", + "(", + "f", + "u", + "e", + "l", + " ", + "+", + " ", + "s", + "t", + "a", + "r", + "t", + "u", + "p", + "_", + "c", + "o", + "s", + "t", + " ", + "*", + " ", + "c", + "o", + "m", + "m", + "i", + "t", + " ", + "+", + " ", + "5", + " ", + "*", + " ", + "b", + "a", + "c", + "k", + "u", + "p", + ")", + ".", + "s", + "u", + "m", + "(", + ")", + ")" + ], "outputs": [], - "source": [ - "m3.solve(reformulate_sos=\"auto\")" - ] + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.935504Z", - "start_time": "2026-04-01T07:35:36.928757Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:30.028114Z", - "iopub.status.busy": "2026-03-06T11:51:30.027864Z", - "iopub.status.idle": "2026-03-06T11:51:30.043138Z", - "shell.execute_reply": "2026-03-06T11:51:30.042813Z", - "shell.execute_reply.started": "2026-03-06T11:51:30.028095Z" + "end_time": "2026-04-01T10:19:44.802729Z", + "start_time": "2026-04-01T10:19:44.735824Z" } }, - "outputs": [], "source": [ - "m3.solution[[\"power\", \"cost\", \"backup\"]].to_pandas()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": "## 4. Tangent lines — Concave efficiency bound\n\nWhen the piecewise function is **concave** and we want to bound y **above**\n(i.e. `y <= f(x)`), we can use `tangent_lines` to get per-segment linear\nexpressions and add them as regular constraints — no SOS2 or binary\nvariables needed. This is the fastest to solve.\n\nHere we bound fuel consumption *below* a concave efficiency curve." - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:36.990196Z", - "start_time": "2026-04-01T07:35:36.947234Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:30.043492Z", - "iopub.status.busy": "2026-03-06T11:51:30.043410Z", - "iopub.status.idle": "2026-03-06T11:51:30.113382Z", - "shell.execute_reply": "2026-03-06T11:51:30.112320Z", - "shell.execute_reply.started": "2026-03-06T11:51:30.043484Z" - } - }, + "m6.solve(reformulate_sos=\"auto\")" + ], "outputs": [], - "source": "x_pts4 = linopy.breakpoints([0, 40, 80, 120])\n# Concave curve: decreasing marginal fuel per MW\ny_pts4 = linopy.breakpoints([0, 50, 90, 120])\n\nm4 = linopy.Model()\n\npower = m4.add_variables(name=\"power\", lower=0, upper=120, coords=[time])\nfuel = m4.add_variables(name=\"fuel\", lower=0, coords=[time])\n\n# tangent_lines returns one LinearExpression per segment — pure LP, no aux variables\nt = linopy.tangent_lines(power, x_pts4, y_pts4)\nm4.add_constraints(fuel <= t, name=\"pwl\")\n\ndemand4 = xr.DataArray([30, 80, 100], coords=[time])\nm4.add_constraints(power == demand4, name=\"demand\")\n# Maximize fuel (to push against the upper bound)\nm4.add_objective(-fuel.sum())" + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:35:37.024642Z", - "start_time": "2026-04-01T07:35:36.992590Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:30.113818Z", - "iopub.status.busy": "2026-03-06T11:51:30.113727Z", - "iopub.status.idle": "2026-03-06T11:51:30.171329Z", - "shell.execute_reply": "2026-03-06T11:51:30.170942Z", - "shell.execute_reply.started": "2026-03-06T11:51:30.113810Z" + "end_time": "2026-04-01T10:19:44.830080Z", + "start_time": "2026-04-01T10:19:44.822947Z" } }, - "outputs": [], "source": [ - "m4.solve(reformulate_sos=\"auto\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:37.032695Z", - "start_time": "2026-04-01T07:35:37.028371Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:30.172009Z", - "iopub.status.busy": "2026-03-06T11:51:30.171791Z", - "iopub.status.idle": "2026-03-06T11:51:30.191956Z", - "shell.execute_reply": "2026-03-06T11:51:30.191556Z", - "shell.execute_reply.started": "2026-03-06T11:51:30.171993Z" - } - }, + "m6.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()" + ], "outputs": [], - "source": [ - "m4.solution[[\"power\", \"fuel\"]].to_pandas()" - ] + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:35:37.125808Z", - "start_time": "2026-04-01T07:35:37.037137Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:30.192604Z", - "iopub.status.busy": "2026-03-06T11:51:30.192376Z", - "iopub.status.idle": "2026-03-06T11:51:30.345074Z", - "shell.execute_reply": "2026-03-06T11:51:30.344642Z", - "shell.execute_reply.started": "2026-03-06T11:51:30.192590Z" + "end_time": "2026-04-01T10:19:44.952553Z", + "start_time": "2026-04-01T10:19:44.836547Z" } }, - "outputs": [], - "source": [ - "bp4 = xr.concat([x_pts4, y_pts4], dim=pd.Index([\"power\", \"fuel\"], name=\"var\"))\n", - "plot_pwl_results(m4, bp4, demand4, color=\"C4\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, "source": [ - "## 5. Slopes mode — 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", - "slopes, x-coordinates, and an initial y-value." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:37.137074Z", - "start_time": "2026-04-01T07:35:37.133725Z" - }, - "execution": { - "iopub.execute_input": "2026-03-06T11:51:30.345523Z", - "iopub.status.busy": "2026-03-06T11:51:30.345404Z", - "iopub.status.idle": "2026-03-06T11:51:30.357312Z", - "shell.execute_reply": "2026-03-06T11:51:30.356954Z", - "shell.execute_reply.started": "2026-03-06T11:51:30.345513Z" - } - }, + "bp6 = linopy.breakpoints({\"power\": x_pts6.values, \"fuel\": y_pts6.values}, dim=\"var\")\n", + "plot_pwl_results(m6, bp6, demand6, color=\"C2\")" + ], "outputs": [], - "source": [ - "# Marginal costs: $1.1/MW for 0-50, $1.5/MW for 50-100, $1.9/MW for 100-150\n", - "x_pts5 = linopy.breakpoints([0, 50, 100, 150])\n", - "y_pts5 = linopy.breakpoints(slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0)\n", - "print(\"y breakpoints from slopes:\", y_pts5.values)" - ] + "execution_count": null }, { "cell_type": "markdown", "metadata": {}, "source": [ - "#", - "#", - " ", - "6", - ".", - " ", "A", - "c", "t", - "i", - "v", - "e", " ", - "p", - "a", - "r", - "a", - "m", - "e", + "*", + "*", "t", + "=", + "1", + "*", + "*", + ",", + " ", + "d", "e", - "r", + "m", + "a", + "n", + "d", " ", - "-", - "-", + "(", + "1", + "5", " ", - "U", - "n", - "i", - "t", + "M", + "W", + ")", " ", - "c", - "o", - "m", - "m", "i", - "t", - "m", - "e", - "n", - "t", + "s", " ", + "b", + "e", + "l", + "o", "w", - "i", + " ", "t", "h", - " ", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", "e", " ", - "e", - "f", - "f", - "i", - "c", + "m", "i", - "e", - "n", - "c", - "y", - "\n", - "\n", - "I", - "n", - " ", - "u", "n", "i", - "t", - " ", - "c", - "o", "m", + "u", "m", - "i", - "t", - "m", - "e", - "n", - "t", " ", - "p", - "r", - "o", - "b", "l", - "e", - "m", - "s", - ",", - " ", + "o", "a", + "d", " ", - "b", - "i", - "n", - "a", - "r", - "y", + "(", + "3", + "0", " ", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", + "M", + "W", + ")", + ".", " ", - "$", - "u", - "_", - "t", - "$", + "T", + "h", + "e", " ", - "c", - "o", - "n", - "t", - "r", + "s", "o", "l", - "s", - " ", - "w", - "h", + "v", + "e", + "r", + "\n", + "k", "e", + "e", + "p", + "s", + " ", "t", "h", "e", - "r", " ", - "a", - "\n", "u", "n", "i", "t", " ", - "i", - "s", - " ", - "*", - "*", - "o", - "n", - "*", - "*", - " ", - "o", - "r", - " ", - "*", - "*", "o", "f", "f", - "*", - "*", - ".", - " ", - "W", - "h", - "e", - "n", " ", + "(", + "`", + "c", "o", - "f", - "f", + "m", + "m", + "i", + "t", + "=", + "0", + "`", + ")", ",", " ", - "b", + "s", "o", - "t", - "h", " ", + "`", "p", "o", "w", "e", "r", - " ", - "o", - "u", - "t", - "p", - "u", - "t", + "=", + "0", + "`", " ", "a", "n", "d", " ", + "`", "f", "u", "e", "l", + "=", + "0", + "`", + " ", + "—", + " ", + "t", + "h", + "e", " ", + "`", + "a", "c", - "o", - "n", - "s", - "u", - "m", - "p", "t", "i", - "o", - "n", + "v", + "e", + "`", "\n", + "p", + "a", + "r", + "a", "m", - "u", - "s", - "t", - " ", - "b", "e", - " ", - "z", + "t", "e", "r", - "o", - ".", - " ", - "W", - "h", - "e", - "n", - " ", - "o", - "n", - ",", " ", - "t", - "h", "e", - " ", - "u", "n", - "i", - "t", - " ", + "f", "o", - "p", - "e", "r", - "a", - "t", + "c", "e", "s", " ", - "w", - "i", "t", "h", "i", - "n", - " ", - "i", - "t", "s", + ".", " ", - "p", - "i", - "e", - "c", + "D", "e", - "w", + "m", + "a", + "n", + "d", + " ", "i", "s", + " ", + "m", "e", - "-", - "l", - "i", - "n", + "t", + " ", + "b", + "y", + " ", + "t", + "h", "e", + " ", + "b", "a", - "r", - "\n", - "e", - "f", - "f", - "i", - "c", - "i", - "e", - "n", "c", - "y", + "k", + "u", + "p", " ", - "c", + "s", + "o", "u", "r", - "v", - "e", - " ", - "b", + "c", "e", + ".", + "\n", + "\n", + "A", "t", - "w", - "e", - "e", - "n", " ", - "$", - "P", - "_", - "{", - "m", - "i", - "n", - "}", - "$", + "*", + "*", + "t", + "=", + "2", + "*", + "*", " ", "a", "n", "d", " ", - "$", - "P", - "_", - "{", - "m", - "a", - "x", - "}", - "$", - ".", - "\n", - "\n", - "T", - "h", - "e", - " ", - "`", - "a", - "c", + "*", + "*", "t", - "i", - "v", - "e", - "`", + "=", + "3", + "*", + "*", + ",", " ", - "k", + "t", + "h", "e", - "y", - "w", - "o", - "r", - "d", " ", - "o", + "u", "n", - " ", - "`", - "a", - "d", - "d", - "_", - "p", - "i", - "e", - "c", - "e", - "w", "i", - "s", - "e", - "_", + "t", + " ", "c", "o", - "n", - "s", - "t", - "r", - "a", + "m", + "m", "i", - "n", "t", "s", - "(", - ")", - "`", " ", - "h", "a", "n", "d", - "l", - "e", - "s", " ", + "o", + "p", + "e", + "r", + "a", "t", - "h", - "i", + "e", "s", " ", - "b", - "y", - "\n", - "g", - "a", - "t", - "i", + "o", "n", - "g", " ", "t", "h", "e", " ", - "i", - "n", - "t", + "P", + "W", + "L", + " ", + "c", + "u", + "r", + "v", "e", + "." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#", + "#", + " ", + "7", + ".", + " ", + "N", + "-", + "v", + "a", "r", - "n", + "i", "a", + "b", "l", - " ", - "P", - "W", - "L", + "e", " ", "f", "o", @@ -1016,907 +6104,659 @@ "o", "n", " ", - "w", - "i", - "t", - "h", - " ", - "t", - "h", - "e", - " ", - "c", - "o", - "m", - "m", - "i", - "t", - "m", - "e", - "n", - "t", - " ", - "b", - "i", - "n", - "a", - "r", - "y", - ":", - "\n", - "\n", + "-", "-", " ", - "*", - "*", - "I", - "n", - "c", - "r", - "e", - "m", - "e", - "n", - "t", - "a", - "l", - ":", - "*", - "*", + "C", + "H", + "P", " ", - "d", - "e", + "p", "l", - "t", "a", - " ", - "b", - "o", - "u", "n", - "d", - "s", - " ", "t", - "i", - "g", + "\n", + "\n", + "W", "h", - "t", "e", "n", " ", - "f", - "r", - "o", "m", - " ", - "$", - "\\", - "d", - "e", + "u", "l", "t", - "a", - "_", "i", - " ", - "\\", + "p", "l", "e", - "q", " ", - "1", - "$", - " ", - "t", "o", - "\n", - " ", - " ", - "$", - "\\", - "d", - "e", - "l", + "u", "t", - "a", - "_", - "i", - " ", - "\\", - "l", - "e", - "q", - " ", + "p", "u", - "$", - ",", + "t", + "s", " ", "a", - "n", - "d", + "r", + "e", " ", - "b", - "a", - "s", + "l", + "i", + "n", + "k", "e", + "d", " ", "t", - "e", + "h", "r", - "m", - "s", + "o", + "u", + "g", + "h", " ", + "s", + "h", "a", "r", "e", + "d", " ", - "m", - "u", - "l", + "o", + "p", + "e", + "r", + "a", "t", "i", + "n", + "g", + " ", "p", - "l", + "o", "i", - "e", - "d", + "n", + "t", + "s", " ", - "b", - "y", + "(", + "e", + ".", + "g", + ".", + ",", " ", - "$", - "u", - "$", + "a", "\n", - "-", - " ", - "*", - "*", - "S", - "O", - "S", - "2", - ":", - "*", - "*", - " ", "c", "o", + "m", + "b", + "i", "n", - "v", "e", - "x", - "i", + "d", + " ", + "h", + "e", + "a", "t", - "y", " ", - "c", - "o", + "a", "n", - "s", - "t", + "d", + " ", + "p", + "o", + "w", + "e", "r", + " ", + "p", + "l", "a", - "i", "n", "t", " ", - "b", + "w", + "h", "e", - "c", + "r", + "e", + " ", + "p", "o", - "m", + "w", "e", - "s", + "r", + ",", " ", - "$", - "\\", - "s", + "f", "u", - "m", - " ", - "\\", + "e", "l", + ",", + " ", "a", - "m", - "b", + "n", "d", + " ", + "h", + "e", "a", - "_", - "i", + "t", " ", - "=", + "a", + "r", + "e", " ", - "u", - "$", - "\n", - "-", + "a", + "l", + "l", " ", - "*", - "*", - "D", - "i", - "s", - "j", + "f", "u", "n", "c", "t", "i", - "v", - "e", - ":", - "*", - "*", + "o", + "n", + "s", + "\n", + "o", + "f", + " ", + "a", " ", "s", - "e", + "i", + "n", "g", - "m", + "l", "e", - "n", - "t", " ", - "s", - "e", "l", - "e", - "c", - "t", - "i", "o", + "a", + "d", + "i", "n", + "g", " ", - "b", - "e", - "c", - "o", + "p", + "a", + "r", + "a", "m", "e", - "s", + "t", + "e", + "r", + ")", + ",", " ", - "$", - "\\", - "s", "u", - "m", + "s", + "e", " ", - "z", - "_", - "k", + "t", + "h", + "e", " ", - "=", + "*", + "*", + "N", + "-", + "v", + "a", + "r", + "i", + "a", + "b", + "l", + "e", + "*", + "*", " ", - "u", - "$", + "A", + "P", + "I", + ".", "\n", "\n", - "T", - "h", - "i", - "s", - " ", - "i", + "I", + "n", "s", - " ", "t", - "h", "e", + "a", + "d", " ", "o", - "n", - "l", - "y", + "f", " ", - "g", + "s", + "e", + "p", + "a", + "r", "a", "t", - "i", - "n", - "g", + "e", + " ", + "x", + "/", + "y", " ", "b", + "r", "e", - "h", "a", - "v", + "k", + "p", + "o", "i", + "n", + "t", + "s", + ",", + " ", + "y", "o", - "r", + "u", " ", - "e", - "x", "p", - "r", - "e", + "a", "s", "s", - "i", - "b", - "l", - "e", " ", - "w", + "a", + " ", + "d", "i", + "c", "t", - "h", - " ", - "p", - "u", - "r", - "e", - " ", - "l", "i", + "o", "n", - "e", "a", "r", + "y", " ", - "c", "o", - "n", - "s", - "t", + "f", + " ", + "e", + "x", + "p", "r", - "a", + "e", + "s", + "s", "i", + "o", "n", - "t", "s", - ".", "\n", - "S", - "e", - "l", - "e", - "c", - "t", - "i", - "v", - "e", - "l", - "y", + "a", + "n", + "d", " ", - "*", - "r", - "e", - "l", "a", - "x", + " ", + "s", "i", "n", "g", - "*", - " ", - "t", - "h", - "e", - " ", - "P", - "W", - "L", - " ", - "(", "l", "e", - "t", - "t", + " ", + "b", + "r", + "e", + "a", + "k", + "p", + "o", "i", "n", - "g", - " ", - "x", - ",", - " ", - "y", + "t", " ", - "f", - "l", - "o", + "D", "a", "t", - " ", - "f", + "a", + "A", "r", - "e", - "e", - "l", + "r", + "a", "y", " ", "w", "h", + "o", + "s", "e", - "n", " ", + "c", "o", - "f", - "f", - ")", - " ", - "w", "o", - "u", - "l", - "d", - "\n", "r", - "e", - "q", - "u", + "d", "i", - "r", + "n", + "a", + "t", "e", + "s", " ", - "b", - "i", - "g", - "-", - "M", + "m", + "a", + "t", + "c", + "h", " ", - "o", - "r", + "t", + "h", + "e", " ", - "i", - "n", "d", "i", "c", - "a", "t", - "o", - "r", - " ", - "c", + "i", "o", "n", - "s", - "t", - "r", "a", - "i", - "n", - "t", + "r", + "y", + " ", + "k", + "e", + "y", "s", "." ] }, { "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:37.147393Z", - "start_time": "2026-04-01T07:35:37.143502Z" - } - }, - "outputs": [], - "source": [ - "# Unit parameters: operates between 30-100 MW when on\n", - "p_min, p_max = 30, 100\n", - "fuel_min, fuel_max = 40, 170\n", - "startup_cost = 50\n", - "\n", - "x_pts6 = linopy.breakpoints([p_min, 60, p_max])\n", - "y_pts6 = linopy.breakpoints([fuel_min, 90, fuel_max])\n", - "print(\"Power breakpoints:\", x_pts6.values)\n", - "print(\"Fuel breakpoints: \", y_pts6.values)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:37.274340Z", - "start_time": "2026-04-01T07:35:37.160988Z" - } - }, - "outputs": [], - "source": "m6 = linopy.Model()\n\npower = m6.add_variables(name=\"power\", lower=0, upper=p_max, coords=[time])\nfuel = m6.add_variables(name=\"fuel\", lower=0, coords=[time])\ncommit = m6.add_variables(name=\"commit\", binary=True, coords=[time])\n\n# The active parameter gates the PWL with the commitment binary:\n# - commit=1: power in [30, 100], fuel = f(power)\n# - commit=0: power = 0, fuel = 0\nm6.add_piecewise_constraints(\n (power, x_pts6),\n (fuel, y_pts6),\n active=commit,\n name=\"pwl\",\n method=\"incremental\",\n)\n\n# Demand: low at t=1 (cheaper to stay off), high at t=2,3\ndemand6 = xr.DataArray([15, 70, 50], coords=[time])\nbackup = m6.add_variables(name=\"backup\", lower=0, coords=[time])\nm6.add_constraints(power + backup >= demand6, name=\"demand\")\n\n# Objective: fuel + startup cost + backup at $5/MW\nm6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())" - }, - { - "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:35:37.421418Z", - "start_time": "2026-04-01T07:35:37.284234Z" + "end_time": "2026-04-01T10:19:44.983662Z", + "start_time": "2026-04-01T10:19:44.966612Z" } }, - "outputs": [], "source": [ - "m6.solve(reformulate_sos=\"auto\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:37.434721Z", - "start_time": "2026-04-01T07:35:37.429918Z" - } - }, + "# CHP operating points: as load increases, power, fuel, and heat all change\n", + "bp_chp = linopy.breakpoints(\n", + " {\n", + " \"power\": [0, 30, 60, 100],\n", + " \"fuel\": [0, 40, 85, 160],\n", + " \"heat\": [0, 25, 55, 95],\n", + " },\n", + " dim=\"var\",\n", + ")\n", + "print(\"CHP breakpoints:\")\n", + "print(bp_chp.to_pandas())" + ], "outputs": [], - "source": [ - "m6.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()" - ] + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:35:37.532796Z", - "start_time": "2026-04-01T07:35:37.442775Z" + "end_time": "2026-04-01T10:19:45.084851Z", + "start_time": "2026-04-01T10:19:44.996249Z" } }, - "outputs": [], - "source": [ - "bp6 = xr.concat([x_pts6, y_pts6], dim=pd.Index([\"power\", \"fuel\"], name=\"var\"))\n", - "plot_pwl_results(m6, bp6, demand6, color=\"C2\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, "source": [ - "A", - "t", - " ", - "*", - "*", - "t", - "=", - "1", - "*", - "*", - ",", - " ", - "d", - "e", "m", - "a", - "n", - "d", - " ", - "(", - "1", - "5", - " ", - "M", - "W", - ")", + "7", " ", - "i", - "s", + "=", " ", - "b", - "e", "l", - "o", - "w", - " ", - "t", - "h", - "e", - " ", - "m", "i", "n", - "i", - "m", - "u", - "m", - " ", - "l", "o", - "a", - "d", - " ", - "(", - "3", - "0", - " ", - "M", - "W", - ")", + "p", + "y", ".", - " ", - "T", - "h", - "e", - " ", - "s", + "M", "o", - "l", - "v", + "d", "e", - "r", + "l", + "(", + ")", + "\n", "\n", - "k", - "e", - "e", "p", - "s", - " ", - "t", - "h", + "o", + "w", "e", + "r", " ", - "u", - "n", - "i", - "t", - " ", - "o", - "f", - "f", + "=", " ", - "(", - "`", - "c", - "o", - "m", "m", + "7", + ".", + "a", + "d", + "d", + "_", + "v", + "a", + "r", "i", - "t", - "=", - "0", - "`", - ")", - ",", - " ", + "a", + "b", + "l", + "e", "s", + "(", + "n", + "a", + "m", + "e", + "=", + "\"", + "p", "o", + "w", + "e", + "r", + "\"", + ",", " ", - "`", - "p", + "l", "o", "w", "e", "r", "=", "0", - "`", - " ", - "a", - "n", - "d", + ",", " ", - "`", - "f", "u", + "p", + "p", "e", - "l", + "r", "=", + "1", "0", - "`", - " ", - "—", - " ", - "t", - "h", - "e", + "0", + ",", " ", - "`", - "a", "c", + "o", + "o", + "r", + "d", + "s", + "=", + "[", "t", "i", - "v", - "e", - "`", - "\n", - "p", - "a", - "r", - "a", "m", "e", - "t", - "e", - "r", - " ", - "e", - "n", + "]", + ")", + "\n", "f", - "o", - "r", - "c", + "u", "e", - "s", + "l", " ", - "t", - "h", - "i", - "s", - ".", + "=", " ", - "D", - "e", "m", + "7", + ".", "a", - "n", "d", - " ", + "d", + "_", + "v", + "a", + "r", "i", - "s", - " ", - "m", - "e", - "t", - " ", + "a", "b", - "y", - " ", - "t", - "h", + "l", "e", - " ", - "b", + "s", + "(", + "n", "a", - "c", - "k", + "m", + "e", + "=", + "\"", + "f", "u", - "p", + "e", + "l", + "\"", + ",", " ", - "s", + "l", "o", - "u", - "r", - "c", + "w", "e", - ".", - "\n", - "\n", - "A", - "t", - " ", - "*", - "*", - "t", + "r", "=", - "2", - "*", - "*", + "0", + ",", " ", - "a", - "n", + "c", + "o", + "o", + "r", "d", - " ", - "*", - "*", - "t", + "s", "=", - "3", - "*", - "*", - ",", - " ", + "[", "t", + "i", + "m", + "e", + "]", + ")", + "\n", "h", "e", - " ", - "u", - "n", - "i", + "a", "t", " ", - "c", - "o", - "m", - "m", - "i", - "t", - "s", + "=", " ", + "m", + "7", + ".", "a", - "n", "d", - " ", - "o", - "p", - "e", + "d", + "_", + "v", + "a", "r", + "i", "a", - "t", + "b", + "l", "e", "s", - " ", - "o", + "(", "n", - " ", - "t", + "a", + "m", + "e", + "=", + "\"", "h", "e", + "a", + "t", + "\"", + ",", " ", - "P", - "W", - "L", + "l", + "o", + "w", + "e", + "r", + "=", + "0", + ",", " ", "c", - "u", + "o", + "o", "r", - "v", + "d", + "s", + "=", + "[", + "t", + "i", + "m", "e", - "." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#", + "]", + ")", + "\n", + "\n", "#", " ", - "7", - ".", - " ", "N", "-", "v", "a", "r", - "i", - "a", - "b", - "l", - "e", - " ", - "f", - "o", - "r", - "m", - "u", - "l", - "a", - "t", - "i", - "o", - "n", - " ", - "-", - "-", - " ", - "C", - "H", - "P", - " ", - "p", - "l", + "i", "a", - "n", - "t", - "\n", - "\n", - "W", - "h", + "b", + "l", "e", - "n", + ":", " ", - "m", - "u", + "a", "l", - "t", - "i", - "p", "l", - "e", " ", - "o", - "u", "t", - "p", - "u", - "t", - "s", - " ", - "a", + "h", "r", "e", + "e", " ", "l", "i", @@ -1940,267 +6780,303 @@ "e", "d", " ", - "o", - "p", + "i", + "n", + "t", "e", "r", + "p", + "o", + "l", "a", "t", "i", - "n", - "g", - " ", - "p", "o", - "i", "n", - "t", - "s", " ", - "(", + "w", "e", - ".", + "i", "g", - ".", - ",", - " ", - "a", + "h", + "t", + "s", "\n", - "c", - "o", "m", - "b", + "7", + ".", + "a", + "d", + "d", + "_", + "p", "i", - "n", "e", - "d", - " ", - "h", + "c", "e", - "a", + "w", + "i", + "s", + "e", + "_", + "c", + "o", + "n", + "s", "t", - " ", + "r", "a", + "i", "n", - "d", + "t", + "s", + "(", + "\n", + " ", " ", + " ", + " ", + "(", "p", "o", "w", "e", "r", + ",", " ", + "b", "p", - "l", - "a", - "n", - "t", - " ", - "w", + "_", + "c", "h", + "p", + ".", + "s", "e", + "l", + "(", + "v", + "a", "r", - "e", - " ", + "=", + "\"", "p", "o", "w", "e", "r", + "\"", + ")", + ")", ",", + "\n", + " ", + " ", " ", + " ", + "(", "f", "u", "e", "l", ",", " ", - "a", - "n", - "d", - " ", + "b", + "p", + "_", + "c", "h", + "p", + ".", + "s", "e", - "a", - "t", - " ", + "l", + "(", + "v", "a", "r", - "e", - " ", - "a", - "l", - "l", - " ", + "=", + "\"", "f", "u", - "n", - "c", - "t", - "i", - "o", - "n", - "s", + "e", + "l", + "\"", + ")", + ")", + ",", "\n", - "o", - "f", " ", - "a", " ", - "s", - "i", - "n", - "g", - "l", - "e", " ", - "l", - "o", + " ", + "(", + "h", + "e", "a", - "d", - "i", - "n", - "g", + "t", + ",", " ", + "b", "p", + "_", + "c", + "h", + "p", + ".", + "s", + "e", + "l", + "(", + "v", "a", "r", - "a", - "m", + "=", + "\"", + "h", "e", + "a", "t", - "e", - "r", + "\"", + ")", ")", ",", + "\n", " ", - "u", - "s", - "e", " ", - "t", - "h", - "e", " ", - "*", - "*", - "N", - "-", - "v", - "a", - "r", - "i", + " ", + "n", "a", - "b", - "l", + "m", "e", - "*", - "*", + "=", + "\"", + "c", + "h", + "p", + "\"", + ",", + "\n", " ", - "A", - "P", - "I", - ".", + " ", + " ", + " ", + "m", + "e", + "t", + "h", + "o", + "d", + "=", + "\"", + "s", + "o", + "s", + "2", + "\"", + ",", "\n", + ")", "\n", - "I", - "n", - "s", - "t", + "\n", + "#", + " ", + "F", + "i", + "x", "e", - "a", "d", " ", + "p", "o", - "f", + "w", + "e", + "r", " ", + "d", + "i", "s", - "e", "p", "a", - "r", - "a", "t", - "e", - " ", - "x", - "/", - "y", + "c", + "h", " ", - "b", - "r", + "d", "e", - "a", - "k", - "p", - "o", + "t", + "e", + "r", + "m", "i", "n", - "t", + "e", "s", - ",", " ", - "y", - "o", - "u", + "t", + "h", + "e", " ", + "o", "p", + "e", + "r", "a", - "s", - "s", - " ", - "a", - " ", - "d", - "i", - "c", "t", "i", - "o", "n", - "a", - "r", - "y", - " ", - "o", - "f", + "g", " ", - "e", - "x", "p", - "r", - "e", - "s", - "s", - "i", "o", + "i", "n", - "s", - "\n", + "t", + " ", + "—", + " ", + "f", + "u", + "e", + "l", + " ", "a", "n", "d", " ", + "h", + "e", "a", + "t", " ", - "s", - "i", - "n", - "g", + "f", + "o", "l", - "e", - " ", - "b", - "r", - "e", - "a", - "k", + "l", + "o", + "w", + "\n", "p", "o", + "w", + "e", + "r", + "_", + "d", "i", - "n", + "s", + "p", + "a", "t", + "c", + "h", + " ", + "=", " ", + "x", + "r", + ".", "D", "a", "t", @@ -2210,130 +7086,177 @@ "r", "a", "y", + "(", + "[", + "2", + "0", + ",", " ", - "w", - "h", - "o", - "s", - "e", + "6", + "0", + ",", + " ", + "9", + "0", + "]", + ",", " ", "c", "o", "o", "r", "d", + "s", + "=", + "[", + "t", "i", + "m", + "e", + "]", + ")", + "\n", + "m", + "7", + ".", + "a", + "d", + "d", + "_", + "c", + "o", "n", + "s", + "t", + "r", "a", + "i", + "n", "t", - "e", "s", + "(", + "p", + "o", + "w", + "e", + "r", " ", - "m", + "=", + "=", + " ", + "p", + "o", + "w", + "e", + "r", + "_", + "d", + "i", + "s", + "p", "a", "t", "c", "h", + ",", " ", - "t", - "h", + "n", + "a", + "m", "e", - " ", + "=", + "\"", + "p", + "o", + "w", + "e", + "r", + "_", "d", "i", + "s", + "p", + "a", + "t", + "c", + "h", + "\"", + ")", + "\n", + "\n", + "m", + "7", + ".", + "a", + "d", + "d", + "_", + "o", + "b", + "j", + "e", "c", "t", "i", - "o", - "n", - "a", - "r", - "y", - " ", - "k", + "v", "e", - "y", + "(", + "f", + "u", + "e", + "l", + ".", "s", - "." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:37.540101Z", - "start_time": "2026-04-01T07:35:37.535579Z" - } - }, - "outputs": [], - "source": [ - "# CHP operating points: as load increases, power, fuel, and heat all change\n", - "bp_chp = linopy.breakpoints(\n", - " {\n", - " \"power\": [0, 30, 60, 100],\n", - " \"fuel\": [0, 40, 85, 160],\n", - " \"heat\": [0, 25, 55, 95],\n", - " },\n", - " dim=\"var\",\n", - ")\n", - "print(\"CHP breakpoints:\")\n", - "print(bp_chp.to_pandas())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T07:35:37.590068Z", - "start_time": "2026-04-01T07:35:37.546834Z" - } - }, + "u", + "m", + "(", + ")", + ")" + ], "outputs": [], - "source": "m7 = linopy.Model()\n\npower = m7.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\nfuel = m7.add_variables(name=\"fuel\", lower=0, coords=[time])\nheat = m7.add_variables(name=\"heat\", lower=0, coords=[time])\n\n# N-variable: all three linked through shared interpolation weights\nm7.add_piecewise_constraints(\n (power, bp_chp.sel(var=\"power\")),\n (fuel, bp_chp.sel(var=\"fuel\")),\n (heat, bp_chp.sel(var=\"heat\")),\n name=\"chp\",\n method=\"sos2\",\n)\n\n# Fixed power dispatch determines the operating point — fuel and heat follow\npower_dispatch = xr.DataArray([20, 60, 90], coords=[time])\nm7.add_constraints(power == power_dispatch, name=\"power_dispatch\")\n\nm7.add_objective(fuel.sum())" + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:35:37.635983Z", - "start_time": "2026-04-01T07:35:37.596785Z" + "end_time": "2026-04-01T10:19:45.169858Z", + "start_time": "2026-04-01T10:19:45.096263Z" } }, - "outputs": [], "source": [ "m7.solve(reformulate_sos=\"auto\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:35:37.662901Z", - "start_time": "2026-04-01T07:35:37.657464Z" + "end_time": "2026-04-01T10:19:45.191988Z", + "start_time": "2026-04-01T10:19:45.182836Z" } }, - "outputs": [], "source": [ "m7.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas().round(2)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T07:35:37.776394Z", - "start_time": "2026-04-01T07:35:37.679698Z" + "end_time": "2026-04-01T10:19:45.453810Z", + "start_time": "2026-04-01T10:19:45.212630Z" } }, - "outputs": [], "source": [ "plot_pwl_results(m7, bp_chp, power_dispatch)" - ] + ], + "outputs": [], + "execution_count": null } ], "metadata": { From 7d404134ba6bac246aa57e694731721fa34e3b3d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:54:48 +0200 Subject: [PATCH 15/30] fix: add coords='minimal' to xr.concat calls for forward compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silences xarray FutureWarning about default coords kwarg changing. No behavior change — we concatenate along new dimensions where coord handling is irrelevant. Co-Authored-By: Claude Opus 4.6 (1M context) --- linopy/piecewise.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 14e04d7b..2035521f 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -141,7 +141,7 @@ def _dict_segments_to_array( for key, seg_list in d.items(): arr = _segments_list_to_array(seg_list) parts.append(arr.expand_dims({dim: [key]})) - combined = xr.concat(parts, dim=dim) + combined = xr.concat(parts, dim=dim, coords="minimal") max_bp = max(max(len(seg) for seg in sl) for sl in d.values()) max_seg = max(len(sl) for sl in d.values()) if combined.sizes[BREAKPOINT_DIM] < max_bp or combined.sizes[SEGMENT_DIM] < max_seg: @@ -982,6 +982,7 @@ def _add_continuous_nvar( stacked_bp = xr.concat( [bp.expand_dims({link_dim: [c]}) for bp, c in zip(bp_list, link_coords)], dim=link_dim, + coords="minimal", ) dim = BREAKPOINT_DIM @@ -1012,7 +1013,7 @@ def _add_continuous_nvar( expr_data_list = [ e.data.expand_dims({link_dim: [c]}) for e, c in zip(lin_exprs, link_coords) ] - stacked_data = xr.concat(expr_data_list, dim=link_dim) + stacked_data = xr.concat(expr_data_list, dim=link_dim, coords="minimal") target_expr = LinearExpression(stacked_data, model) # Compute lambda mask @@ -1021,6 +1022,7 @@ def _add_continuous_nvar( stacked_mask = xr.concat( [bp_mask.expand_dims({link_dim: [c]}) for c in link_coords], dim=link_dim, + coords="minimal", ) lambda_mask = stacked_mask.any(dim=link_dim) @@ -1065,6 +1067,7 @@ def _add_continuous_nvar( stacked_mask = xr.concat( [bp_mask.expand_dims({link_dim: [c]}) for c in link_coords], dim=link_dim, + coords="minimal", ) bp_mask_agg = stacked_mask.all(dim=link_dim) mask_lo = bp_mask_agg.isel({dim: slice(None, -1)}).rename({dim: seg_dim}) From dcf77d21d072785ebfad5e8b7e14d21209a281ae Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:03:02 +0200 Subject: [PATCH 16/30] feat: add per-entity breakpoints example, fix scalar coord handling Add Example 8 (fleet of generators with per-entity breakpoints) to the notebook. Also drop scalar coordinates from breakpoints before stacking to handle bp.sel(var="power") without MergeError. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/piecewise-linear-constraints.ipynb | 8479 +++++-------------- linopy/piecewise.py | 6 +- 2 files changed, 2089 insertions(+), 6396 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 742f200c..904ce8d7 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -3,5927 +3,1362 @@ { "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 | Tangent lines |\n| 5 | Gas unit with commitment | On/off + min load | Incremental + `active` |\n| 6 | CHP plant (N-variable) | Joint power/fuel/heat | N-variable SOS2 |\n| 7 | Fleet of generators | Per-entity breakpoints | Per-generator curves |\n\n**API:** Each `(expression, breakpoints)` tuple links a variable to its breakpoints.\nAll tuples share interpolation weights, coupling them on the same curve segment.\n\n```python\nm.add_piecewise_constraints((power, x_pts), (fuel, y_pts))\n```" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:26.188969Z", + "start_time": "2026-04-01T11:02:26.183809Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.167007Z", + "iopub.status.busy": "2026-03-06T11:51:29.166576Z", + "iopub.status.idle": "2026-03-06T11:51:29.185103Z", + "shell.execute_reply": "2026-03-06T11:51:29.184712Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.166974Z" + } + }, + "outputs": [], "source": [ - "#", - " ", - "P", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - " ", - "L", - "i", - "n", - "e", - "a", - "r", - " ", - "C", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - " ", - "T", - "u", - "t", - "o", - "r", - "i", - "a", - "l", - "\n", + "import matplotlib.pyplot as plt\n", + "import pandas as pd\n", + "import xarray as xr\n", "\n", - "T", - "h", - "i", - "s", - " ", - "n", - "o", - "t", - "e", - "b", - "o", - "o", - "k", - " ", - "d", - "e", - "m", - "o", - "n", - "s", - "t", - "r", - "a", - "t", - "e", - "s", - " ", - "l", - "i", - "n", - "o", - "p", - "y", - "'", - "s", - " ", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - " ", - "l", - "i", - "n", - "e", - "a", - "r", - " ", - "(", - "P", - "W", - "L", - ")", - " ", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - " ", - "f", - "o", - "r", - "m", - "u", - "l", - "a", - "t", - "i", - "o", - "n", - "s", - ".", + "import linopy\n", "\n", - "E", - "a", - "c", - "h", - " ", - "e", - "x", - "a", - "m", - "p", - "l", - "e", - " ", - "b", - "u", - "i", - "l", - "d", - "s", - " ", - "a", - " ", - "s", - "e", - "p", - "a", - "r", - "a", - "t", - "e", - " ", - "d", - "i", - "s", - "p", - "a", - "t", - "c", - "h", - " ", - "m", - "o", - "d", - "e", - "l", - " ", - "w", - "h", - "e", - "r", - "e", - " ", - "a", - " ", - "s", - "i", - "n", - "g", - "l", - "e", - " ", - "p", - "o", - "w", - "e", - "r", - " ", - "p", - "l", - "a", - "n", - "t", - " ", - "m", - "u", - "s", - "t", - " ", - "m", - "e", - "e", - "t", + "time = pd.Index([1, 2, 3], name=\"time\")\n", "\n", - "a", - " ", - "t", - "i", - "m", - "e", - "-", - "v", - "a", - "r", - "y", - "i", - "n", - "g", - " ", - "d", - "e", - "m", - "a", - "n", - "d", - ".", "\n", + "def plot_pwl_results(model, breakpoints, demand, *, x_name=\"power\", color=\"C0\"):\n", + " \"\"\"\n", + " Plot PWL curves with operating points and dispatch vs demand.\n", "\n", - "|", - " ", - "E", - "x", - "a", - "m", - "p", - "l", - "e", - " ", - "|", - " ", - "P", - "l", - "a", - "n", - "t", - " ", - "|", - " ", - "L", - "i", - "m", - "i", - "t", - "a", - "t", - "i", - "o", - "n", - " ", - "|", - " ", - "F", - "o", - "r", - "m", - "u", - "l", - "a", - "t", - "i", - "o", - "n", - " ", - "|", + " Parameters\n", + " ----------\n", + " model : linopy.Model\n", + " Solved model.\n", + " breakpoints : DataArray\n", + " Breakpoints array. For 2-variable cases pass a DataArray with a\n", + " \"var\" dimension containing two coordinates (x and y variable names).\n", + " Alternatively pass two separate arrays and they will be stacked.\n", + " demand : DataArray\n", + " Demand time series (plotted as step line).\n", + " x_name : str\n", + " Name of the x-axis variable (used for the curve plot).\n", + " color : str\n", + " Base color for the plot.\n", + " \"\"\"\n", + " sol = model.solution\n", + " var_names = list(breakpoints.coords[\"var\"].values)\n", + " bp_x = breakpoints.sel(var=x_name).values\n", "\n", - "|", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "|", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "|", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "|", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "-", - "|", + " fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3.5))\n", "\n", - "|", - " ", - "1", - " ", - "|", - " ", - "G", - "a", - "s", - " ", - "t", - "u", - "r", - "b", - "i", - "n", - "e", - " ", - "(", - "0", - "-", - "1", - "0", - "0", - " ", - "M", - "W", - ")", - " ", - "|", - " ", - "C", - "o", - "n", - "v", - "e", - "x", - " ", - "h", - "e", - "a", - "t", - " ", - "r", - "a", - "t", - "e", - " ", - "|", - " ", - "S", - "O", - "S", - "2", - " ", - "|", + " # Left: breakpoint curves with operating points\n", + " colors = [f\"C{i}\" for i in range(len(var_names))]\n", + " for var, c in zip(var_names, colors):\n", + " if var == x_name:\n", + " continue\n", + " bp_y = breakpoints.sel(var=var).values\n", + " ax1.plot(bp_x, bp_y, \"o-\", color=c, label=f\"{var} (breakpoints)\")\n", + " for t in time:\n", + " ax1.plot(\n", + " float(sol[x_name].sel(time=t)),\n", + " float(sol[var].sel(time=t)),\n", + " \"D\",\n", + " color=c,\n", + " ms=10,\n", + " )\n", + " ax1.set(xlabel=x_name.title(), title=\"PWL curve\")\n", + " ax1.legend()\n", "\n", - "|", - " ", - "2", - " ", - "|", - " ", - "C", - "o", - "a", - "l", - " ", - "p", - "l", - "a", - "n", - "t", - " ", - "(", - "0", - "-", - "1", - "5", - "0", - " ", - "M", - "W", - ")", - " ", - "|", - " ", - "M", - "o", - "n", - "o", - "t", - "o", - "n", - "i", - "c", - " ", - "h", - "e", - "a", - "t", - " ", - "r", - "a", - "t", - "e", - " ", - "|", - " ", - "I", - "n", - "c", - "r", - "e", - "m", - "e", - "n", - "t", - "a", - "l", - " ", - "|", + " # Right: dispatch vs demand\n", + " x = list(range(len(time)))\n", + " power_vals = sol[x_name].values\n", + " ax2.bar(x, power_vals, color=color, label=x_name.title())\n", + " if \"backup\" in sol:\n", + " ax2.bar(\n", + " x,\n", + " sol[\"backup\"].values,\n", + " bottom=power_vals,\n", + " color=\"C3\",\n", + " alpha=0.5,\n", + " label=\"Backup\",\n", + " )\n", + " ax2.step(\n", + " [v - 0.5 for v in x] + [x[-1] + 0.5],\n", + " list(demand.values) + [demand.values[-1]],\n", + " where=\"post\",\n", + " color=\"black\",\n", + " lw=2,\n", + " label=\"Demand\",\n", + " )\n", + " ax2.set(\n", + " xlabel=\"Time\",\n", + " ylabel=\"MW\",\n", + " title=\"Dispatch\",\n", + " xticks=x,\n", + " xticklabels=time.values,\n", + " )\n", + " ax2.legend()\n", + " plt.tight_layout()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. SOS2 formulation — Gas turbine\n", "\n", - "|", - " ", - "3", - " ", - "|", - " ", - "D", - "i", - "e", - "s", - "e", - "l", - " ", - "g", - "e", - "n", - "e", - "r", - "a", - "t", - "o", - "r", - " ", - "(", - "o", - "f", - "f", - " ", - "o", - "r", - " ", - "5", - "0", - "-", - "8", - "0", - " ", - "M", - "W", - ")", - " ", - "|", - " ", - "F", - "o", - "r", - "b", - "i", - "d", - "d", - "e", - "n", - " ", - "z", - "o", - "n", - "e", - " ", - "|", - " ", - "D", - "i", - "s", - "j", - "u", - "n", - "c", - "t", - "i", - "v", - "e", - " ", - "|", + "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", + "to link power output and fuel consumption via separate x/y breakpoints." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:26.200443Z", + "start_time": "2026-04-01T11:02:26.196608Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.185693Z", + "iopub.status.busy": "2026-03-06T11:51:29.185601Z", + "iopub.status.idle": "2026-03-06T11:51:29.199760Z", + "shell.execute_reply": "2026-03-06T11:51:29.199416Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.185683Z" + } + }, + "outputs": [], + "source": [ + "x_pts1 = linopy.breakpoints([0, 30, 60, 100])\n", + "y_pts1 = linopy.breakpoints([0, 36, 84, 170])\n", + "print(\"x_pts:\", x_pts1.values)\n", + "print(\"y_pts:\", y_pts1.values)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:26.251435Z", + "start_time": "2026-04-01T11:02:26.207916Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.200170Z", + "iopub.status.busy": "2026-03-06T11:51:29.200087Z", + "iopub.status.idle": "2026-03-06T11:51:29.266847Z", + "shell.execute_reply": "2026-03-06T11:51:29.266379Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.200161Z" + } + }, + "outputs": [], + "source": [ + "m1 = linopy.Model()\n", "\n", - "|", - " ", - "4", - " ", - "|", - " ", - "C", - "o", - "n", - "c", - "a", - "v", - "e", - " ", - "e", - "f", - "f", - "i", - "c", - "i", - "e", - "n", - "c", - "y", - " ", - "c", - "u", - "r", - "v", - "e", - " ", - "|", - " ", - "I", - "n", - "e", - "q", - "u", - "a", - "l", - "i", - "t", - "y", - " ", - "b", - "o", - "u", - "n", - "d", - " ", - "|", - " ", - "T", - "a", - "n", - "g", - "e", - "n", - "t", - " ", - "l", - "i", - "n", - "e", - "s", - " ", - "|", + "power = m1.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", + "fuel = m1.add_variables(name=\"fuel\", lower=0, coords=[time])\n", "\n", - "|", - " ", - "5", - " ", - "|", - " ", - "G", - "a", - "s", - " ", - "u", - "n", - "i", - "t", - " ", - "w", - "i", - "t", - "h", - " ", - "c", - "o", - "m", - "m", - "i", - "t", - "m", - "e", - "n", - "t", - " ", - "|", - " ", - "O", - "n", - "/", - "o", - "f", - "f", - " ", - "+", - " ", - "m", - "i", - "n", - " ", - "l", - "o", - "a", - "d", - " ", - "|", - " ", - "I", - "n", - "c", - "r", - "e", - "m", - "e", - "n", - "t", - "a", - "l", - " ", - "+", - " ", - "`", - "a", - "c", - "t", - "i", - "v", - "e", - "`", - " ", - "|", + "# breakpoints are auto-broadcast to match the time dimension\n", + "m1.add_piecewise_constraints(\n", + " (power, x_pts1),\n", + " (fuel, y_pts1),\n", + " name=\"pwl\",\n", + " method=\"sos2\",\n", + ")\n", "\n", - "|", - " ", - "6", - " ", - "|", - " ", - "C", - "H", - "P", - " ", - "p", - "l", - "a", - "n", - "t", - " ", - "(", - "N", - "-", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - ")", - " ", - "|", - " ", - "J", - "o", - "i", - "n", - "t", - " ", - "p", - "o", - "w", - "e", - "r", - "/", - "f", - "u", - "e", - "l", - "/", - "h", - "e", - "a", - "t", - " ", - "|", - " ", - "N", - "-", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - " ", - "S", - "O", - "S", - "2", - " ", - "|", + "demand1 = xr.DataArray([50, 80, 30], coords=[time])\n", + "m1.add_constraints(power >= demand1, name=\"demand\")\n", + "m1.add_objective(fuel.sum())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:26.308193Z", + "start_time": "2026-04-01T11:02:26.255362Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.267522Z", + "iopub.status.busy": "2026-03-06T11:51:29.267433Z", + "iopub.status.idle": "2026-03-06T11:51:29.326758Z", + "shell.execute_reply": "2026-03-06T11:51:29.326518Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.267514Z" + } + }, + "outputs": [], + "source": [ + "m1.solve(reformulate_sos=\"auto\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:26.330720Z", + "start_time": "2026-04-01T11:02:26.323039Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.327139Z", + "iopub.status.busy": "2026-03-06T11:51:29.327044Z", + "iopub.status.idle": "2026-03-06T11:51:29.339334Z", + "shell.execute_reply": "2026-03-06T11:51:29.338974Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.327130Z" + } + }, + "outputs": [], + "source": [ + "m1.solution[[\"power\", \"fuel\"]].to_pandas()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:26.512495Z", + "start_time": "2026-04-01T11:02:26.341217Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.339689Z", + "iopub.status.busy": "2026-03-06T11:51:29.339608Z", + "iopub.status.idle": "2026-03-06T11:51:29.489677Z", + "shell.execute_reply": "2026-03-06T11:51:29.489280Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.339680Z" + } + }, + "outputs": [], + "source": [ + "bp1 = linopy.breakpoints({\"power\": x_pts1.values, \"fuel\": y_pts1.values}, dim=\"var\")\n", + "plot_pwl_results(m1, bp1, demand1, color=\"C0\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Incremental formulation — 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." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:26.582521Z", + "start_time": "2026-04-01T11:02:26.577758Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.490092Z", + "iopub.status.busy": "2026-03-06T11:51:29.490011Z", + "iopub.status.idle": "2026-03-06T11:51:29.500894Z", + "shell.execute_reply": "2026-03-06T11:51:29.500558Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.490084Z" + } + }, + "outputs": [], + "source": [ + "x_pts2 = linopy.breakpoints([0, 50, 100, 150])\n", + "y_pts2 = linopy.breakpoints([0, 55, 130, 225])\n", + "print(\"x_pts:\", x_pts2.values)\n", + "print(\"y_pts:\", y_pts2.values)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:26.673055Z", + "start_time": "2026-04-01T11:02:26.598926Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.501317Z", + "iopub.status.busy": "2026-03-06T11:51:29.501216Z", + "iopub.status.idle": "2026-03-06T11:51:29.604024Z", + "shell.execute_reply": "2026-03-06T11:51:29.603543Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.501307Z" + } + }, + "outputs": [], + "source": [ + "m2 = linopy.Model()\n", "\n", - "*", - "*", - "A", - "P", - "I", - ":", - "*", - "*", - " ", - "E", - "a", - "c", - "h", - " ", - "`", - "(", - "e", - "x", - "p", - "r", - "e", - "s", - "s", - "i", - "o", - "n", - ",", - " ", - "b", - "r", - "e", - "a", - "k", - "p", - "o", - "i", - "n", - "t", - "s", - ")", - "`", - " ", - "t", - "u", - "p", - "l", - "e", - " ", - "l", - "i", - "n", - "k", - "s", - " ", - "a", - " ", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - " ", - "t", - "o", - " ", - "i", - "t", - "s", - " ", - "b", - "r", - "e", - "a", - "k", - "p", - "o", - "i", - "n", - "t", - "s", - ".", + "power = m2.add_variables(name=\"power\", lower=0, upper=150, coords=[time])\n", + "fuel = m2.add_variables(name=\"fuel\", lower=0, coords=[time])\n", "\n", - "A", - "l", - "l", - " ", - "t", - "u", - "p", - "l", - "e", - "s", - " ", - "s", - "h", - "a", - "r", - "e", - " ", - "i", - "n", - "t", - "e", - "r", - "p", - "o", - "l", - "a", - "t", - "i", - "o", - "n", - " ", - "w", - "e", - "i", - "g", - "h", - "t", - "s", - ",", - " ", - "c", - "o", - "u", - "p", - "l", - "i", - "n", - "g", - " ", - "t", - "h", - "e", - "m", - " ", - "o", - "n", - " ", - "t", - "h", - "e", - " ", - "s", - "a", - "m", - "e", - " ", - "c", - "u", - "r", - "v", - "e", - " ", - "s", - "e", - "g", - "m", - "e", - "n", - "t", - ".", - "\n", - "\n", - "`", - "`", - "`", - "p", - "y", - "t", - "h", - "o", - "n", - "\n", - "m", - ".", - "a", - "d", - "d", - "_", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - "_", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - "(", - "(", - "p", - "o", - "w", - "e", - "r", - ",", - " ", - "x", - "_", - "p", - "t", - "s", - ")", - ",", - " ", - "(", - "f", - "u", - "e", - "l", - ",", - " ", - "y", - "_", - "p", - "t", - "s", - ")", - ")", + "m2.add_piecewise_constraints(\n", + " (power, x_pts2),\n", + " (fuel, y_pts2),\n", + " name=\"pwl\",\n", + " method=\"incremental\",\n", + ")\n", "\n", - "`", - "`", - "`" + "demand2 = xr.DataArray([80, 120, 50], coords=[time])\n", + "m2.add_constraints(power >= demand2, name=\"demand\")\n", + "m2.add_objective(fuel.sum())" ] }, { "cell_type": "code", + "execution_count": null, "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.167007Z", - "iopub.status.busy": "2026-03-06T11:51:29.166576Z", - "iopub.status.idle": "2026-03-06T11:51:29.185103Z", - "shell.execute_reply": "2026-03-06T11:51:29.184712Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.166974Z" - }, "ExecuteTime": { - "end_time": "2026-04-01T10:19:43.561021Z", - "start_time": "2026-04-01T10:19:42.543401Z" + "end_time": "2026-04-01T11:02:26.734253Z", + "start_time": "2026-04-01T11:02:26.677880Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.604434Z", + "iopub.status.busy": "2026-03-06T11:51:29.604359Z", + "iopub.status.idle": "2026-03-06T11:51:29.680947Z", + "shell.execute_reply": "2026-03-06T11:51:29.680667Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.604427Z" } }, - "source": [ - "import matplotlib.pyplot as plt\n", - "import pandas as pd\n", - "import xarray as xr\n", - "\n", - "import linopy\n", - "\n", - "time = pd.Index([1, 2, 3], name=\"time\")\n", - "\n", - "\n", - "def plot_pwl_results(model, breakpoints, demand, *, x_name=\"power\", color=\"C0\"):\n", - " \"\"\"\n", - " Plot PWL curves with operating points and dispatch vs demand.\n", - "\n", - " Parameters\n", - " ----------\n", - " model : linopy.Model\n", - " Solved model.\n", - " breakpoints : DataArray\n", - " Breakpoints array. For 2-variable cases pass a DataArray with a\n", - " \"var\" dimension containing two coordinates (x and y variable names).\n", - " Alternatively pass two separate arrays and they will be stacked.\n", - " demand : DataArray\n", - " Demand time series (plotted as step line).\n", - " x_name : str\n", - " Name of the x-axis variable (used for the curve plot).\n", - " color : str\n", - " Base color for the plot.\n", - " \"\"\"\n", - " sol = model.solution\n", - " var_names = list(breakpoints.coords[\"var\"].values)\n", - " bp_x = breakpoints.sel(var=x_name).values\n", - "\n", - " fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3.5))\n", - "\n", - " # Left: breakpoint curves with operating points\n", - " colors = [f\"C{i}\" for i in range(len(var_names))]\n", - " for var, c in zip(var_names, colors):\n", - " if var == x_name:\n", - " continue\n", - " bp_y = breakpoints.sel(var=var).values\n", - " ax1.plot(bp_x, bp_y, \"o-\", color=c, label=f\"{var} (breakpoints)\")\n", - " for t in time:\n", - " ax1.plot(\n", - " float(sol[x_name].sel(time=t)),\n", - " float(sol[var].sel(time=t)),\n", - " \"D\",\n", - " color=c,\n", - " ms=10,\n", - " )\n", - " ax1.set(xlabel=x_name.title(), title=\"PWL curve\")\n", - " ax1.legend()\n", - "\n", - " # Right: dispatch vs demand\n", - " x = list(range(len(time)))\n", - " power_vals = sol[x_name].values\n", - " ax2.bar(x, power_vals, color=color, label=x_name.title())\n", - " if \"backup\" in sol:\n", - " ax2.bar(\n", - " x,\n", - " sol[\"backup\"].values,\n", - " bottom=power_vals,\n", - " color=\"C3\",\n", - " alpha=0.5,\n", - " label=\"Backup\",\n", - " )\n", - " ax2.step(\n", - " [v - 0.5 for v in x] + [x[-1] + 0.5],\n", - " list(demand.values) + [demand.values[-1]],\n", - " where=\"post\",\n", - " color=\"black\",\n", - " lw=2,\n", - " label=\"Demand\",\n", - " )\n", - " ax2.set(\n", - " xlabel=\"Time\",\n", - " ylabel=\"MW\",\n", - " title=\"Dispatch\",\n", - " xticks=x,\n", - " xticklabels=time.values,\n", - " )\n", - " ax2.legend()\n", - " plt.tight_layout()" - ], "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, "source": [ - "## 1. SOS2 formulation — 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", - "to link power output and fuel consumption via separate x/y breakpoints." + "m2.solve(reformulate_sos=\"auto\");" ] }, { "cell_type": "code", + "execution_count": null, "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.185693Z", - "iopub.status.busy": "2026-03-06T11:51:29.185601Z", - "iopub.status.idle": "2026-03-06T11:51:29.199760Z", - "shell.execute_reply": "2026-03-06T11:51:29.199416Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.185683Z" - }, "ExecuteTime": { - "end_time": "2026-04-01T10:19:43.607329Z", - "start_time": "2026-04-01T10:19:43.563753Z" + "end_time": "2026-04-01T11:02:26.752710Z", + "start_time": "2026-04-01T11:02:26.743897Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.681833Z", + "iopub.status.busy": "2026-03-06T11:51:29.681725Z", + "iopub.status.idle": "2026-03-06T11:51:29.698558Z", + "shell.execute_reply": "2026-03-06T11:51:29.698011Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.681822Z" } }, + "outputs": [], "source": [ - "x_pts1 = linopy.breakpoints([0, 30, 60, 100])\n", - "y_pts1 = linopy.breakpoints([0, 36, 84, 170])\n", - "print(\"x_pts:\", x_pts1.values)\n", - "print(\"y_pts:\", y_pts1.values)" - ], - "outputs": [], - "execution_count": null + "m2.solution[[\"power\", \"fuel\"]].to_pandas()" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.200170Z", - "iopub.status.busy": "2026-03-06T11:51:29.200087Z", - "iopub.status.idle": "2026-03-06T11:51:29.266847Z", - "shell.execute_reply": "2026-03-06T11:51:29.266379Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.200161Z" - }, "ExecuteTime": { - "end_time": "2026-04-01T10:19:43.655062Z", - "start_time": "2026-04-01T10:19:43.614598Z" + "end_time": "2026-04-01T11:02:26.868254Z", + "start_time": "2026-04-01T11:02:26.763276Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.699350Z", + "iopub.status.busy": "2026-03-06T11:51:29.699116Z", + "iopub.status.idle": "2026-03-06T11:51:29.852000Z", + "shell.execute_reply": "2026-03-06T11:51:29.851741Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.699334Z" } }, + "outputs": [], "source": [ - "m", - "1", - " ", - "=", - " ", - "l", - "i", - "n", - "o", - "p", - "y", - ".", - "M", - "o", - "d", - "e", - "l", - "(", - ")", - "\n", - "\n", - "p", - "o", - "w", - "e", - "r", - " ", - "=", - " ", - "m", - "1", - ".", - "a", - "d", - "d", - "_", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - "s", - "(", - "n", - "a", - "m", - "e", - "=", - "\"", - "p", - "o", - "w", - "e", - "r", - "\"", - ",", - " ", - "l", - "o", - "w", - "e", - "r", - "=", - "0", - ",", - " ", - "u", - "p", - "p", - "e", - "r", - "=", - "1", - "0", - "0", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "f", - "u", - "e", - "l", - " ", - "=", - " ", - "m", - "1", - ".", - "a", - "d", - "d", - "_", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - "s", - "(", - "n", - "a", - "m", - "e", - "=", - "\"", - "f", - "u", - "e", - "l", - "\"", - ",", - " ", - "l", - "o", - "w", - "e", - "r", - "=", - "0", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", + "bp2 = linopy.breakpoints({\"power\": x_pts2.values, \"fuel\": y_pts2.values}, dim=\"var\")\n", + "plot_pwl_results(m2, bp2, demand2, color=\"C1\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Disjunctive formulation — Diesel generator\n", "\n", - "#", - " ", - "b", - "r", - "e", - "a", - "k", - "p", - "o", - "i", - "n", - "t", - "s", - " ", - "a", - "r", - "e", - " ", - "a", - "u", - "t", - "o", - "-", - "b", - "r", - "o", - "a", - "d", - "c", - "a", - "s", - "t", - " ", - "t", - "o", - " ", - "m", - "a", - "t", - "c", - "h", - " ", - "t", - "h", - "e", - " ", - "t", - "i", - "m", - "e", - " ", - "d", - "i", - "m", - "e", - "n", - "s", - "i", - "o", - "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", + "**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", "\n", - "m", - "1", - ".", - "a", - "d", - "d", - "_", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - "_", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - "(", - "\n", - " ", - " ", - " ", - " ", - "(", - "p", - "o", - "w", - "e", - "r", - ",", - " ", - "x", - "_", - "p", - "t", - "s", - "1", - ")", - ",", - "\n", - " ", - " ", - " ", - " ", - "(", - "f", - "u", - "e", - "l", - ",", - " ", - "y", - "_", - "p", - "t", - "s", - "1", - ")", - ",", - "\n", - " ", - " ", - " ", - " ", - "n", - "a", - "m", - "e", - "=", - "\"", - "p", - "w", - "l", - "\"", - ",", - "\n", - " ", - " ", - " ", - " ", - "m", - "e", - "t", - "h", - "o", - "d", - "=", - "\"", - "s", - "o", - "s", - "2", - "\"", - ",", - "\n", - ")", - "\n", - "\n", - "d", - "e", - "m", - "a", - "n", - "d", - "1", - " ", - "=", - " ", - "x", - "r", - ".", - "D", - "a", - "t", - "a", - "A", - "r", - "r", - "a", - "y", - "(", - "[", - "5", - "0", - ",", - " ", - "8", - "0", - ",", - " ", - "3", - "0", - "]", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "m", - "1", - ".", - "a", - "d", - "d", - "_", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - "(", - "p", - "o", - "w", - "e", - "r", - " ", - ">", - "=", - " ", - "d", - "e", - "m", - "a", - "n", - "d", - "1", - ",", - " ", - "n", - "a", - "m", - "e", - "=", - "\"", - "d", - "e", - "m", - "a", - "n", - "d", - "\"", - ")", - "\n", - "m", - "1", - ".", - "a", - "d", - "d", - "_", - "o", - "b", - "j", - "e", - "c", - "t", - "i", - "v", - "e", - "(", - "f", - "u", - "e", - "l", - ".", - "s", - "u", - "m", - "(", - ")", - ")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.267522Z", - "iopub.status.busy": "2026-03-06T11:51:29.267433Z", - "iopub.status.idle": "2026-03-06T11:51:29.326758Z", - "shell.execute_reply": "2026-03-06T11:51:29.326518Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.267514Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:43.708234Z", - "start_time": "2026-04-01T10:19:43.657664Z" - } - }, - "source": [ - "m1.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.327139Z", - "iopub.status.busy": "2026-03-06T11:51:29.327044Z", - "iopub.status.idle": "2026-03-06T11:51:29.339334Z", - "shell.execute_reply": "2026-03-06T11:51:29.338974Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.327130Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:43.720685Z", - "start_time": "2026-04-01T10:19:43.714174Z" - } - }, - "source": [ - "m1.solution[[\"power\", \"fuel\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null + "The disjunctive formulation is selected automatically when the breakpoint\n", + "arrays have a segment dimension (created by `linopy.segments()`)." + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.339689Z", - "iopub.status.busy": "2026-03-06T11:51:29.339608Z", - "iopub.status.idle": "2026-03-06T11:51:29.489677Z", - "shell.execute_reply": "2026-03-06T11:51:29.489280Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.339680Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:43.909787Z", - "start_time": "2026-04-01T10:19:43.740759Z" - } - }, - "source": [ - "bp1 = linopy.breakpoints({\"power\": x_pts1.values, \"fuel\": y_pts1.values}, dim=\"var\")\n", - "plot_pwl_results(m1, bp1, demand1, color=\"C0\")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 2. Incremental formulation — 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." - ] - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.490092Z", - "iopub.status.busy": "2026-03-06T11:51:29.490011Z", - "iopub.status.idle": "2026-03-06T11:51:29.500894Z", - "shell.execute_reply": "2026-03-06T11:51:29.500558Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.490084Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:43.926001Z", - "start_time": "2026-04-01T10:19:43.921143Z" - } - }, - "source": [ - "x_pts2 = linopy.breakpoints([0, 50, 100, 150])\n", - "y_pts2 = linopy.breakpoints([0, 55, 130, 225])\n", - "print(\"x_pts:\", x_pts2.values)\n", - "print(\"y_pts:\", y_pts2.values)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.501317Z", - "iopub.status.busy": "2026-03-06T11:51:29.501216Z", - "iopub.status.idle": "2026-03-06T11:51:29.604024Z", - "shell.execute_reply": "2026-03-06T11:51:29.603543Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.501307Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.020850Z", - "start_time": "2026-04-01T10:19:43.930951Z" - } - }, - "source": [ - "m", - "2", - " ", - "=", - " ", - "l", - "i", - "n", - "o", - "p", - "y", - ".", - "M", - "o", - "d", - "e", - "l", - "(", - ")", - "\n", - "\n", - "p", - "o", - "w", - "e", - "r", - " ", - "=", - " ", - "m", - "2", - ".", - "a", - "d", - "d", - "_", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - "s", - "(", - "n", - "a", - "m", - "e", - "=", - "\"", - "p", - "o", - "w", - "e", - "r", - "\"", - ",", - " ", - "l", - "o", - "w", - "e", - "r", - "=", - "0", - ",", - " ", - "u", - "p", - "p", - "e", - "r", - "=", - "1", - "5", - "0", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "f", - "u", - "e", - "l", - " ", - "=", - " ", - "m", - "2", - ".", - "a", - "d", - "d", - "_", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - "s", - "(", - "n", - "a", - "m", - "e", - "=", - "\"", - "f", - "u", - "e", - "l", - "\"", - ",", - " ", - "l", - "o", - "w", - "e", - "r", - "=", - "0", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "\n", - "m", - "2", - ".", - "a", - "d", - "d", - "_", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - "_", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - "(", - "\n", - " ", - " ", - " ", - " ", - "(", - "p", - "o", - "w", - "e", - "r", - ",", - " ", - "x", - "_", - "p", - "t", - "s", - "2", - ")", - ",", - "\n", - " ", - " ", - " ", - " ", - "(", - "f", - "u", - "e", - "l", - ",", - " ", - "y", - "_", - "p", - "t", - "s", - "2", - ")", - ",", - "\n", - " ", - " ", - " ", - " ", - "n", - "a", - "m", - "e", - "=", - "\"", - "p", - "w", - "l", - "\"", - ",", - "\n", - " ", - " ", - " ", - " ", - "m", - "e", - "t", - "h", - "o", - "d", - "=", - "\"", - "i", - "n", - "c", - "r", - "e", - "m", - "e", - "n", - "t", - "a", - "l", - "\"", - ",", - "\n", - ")", - "\n", - "\n", - "d", - "e", - "m", - "a", - "n", - "d", - "2", - " ", - "=", - " ", - "x", - "r", - ".", - "D", - "a", - "t", - "a", - "A", - "r", - "r", - "a", - "y", - "(", - "[", - "8", - "0", - ",", - " ", - "1", - "2", - "0", - ",", - " ", - "5", - "0", - "]", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "m", - "2", - ".", - "a", - "d", - "d", - "_", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - "(", - "p", - "o", - "w", - "e", - "r", - " ", - ">", - "=", - " ", - "d", - "e", - "m", - "a", - "n", - "d", - "2", - ",", - " ", - "n", - "a", - "m", - "e", - "=", - "\"", - "d", - "e", - "m", - "a", - "n", - "d", - "\"", - ")", - "\n", - "m", - "2", - ".", - "a", - "d", - "d", - "_", - "o", - "b", - "j", - "e", - "c", - "t", - "i", - "v", - "e", - "(", - "f", - "u", - "e", - "l", - ".", - "s", - "u", - "m", - "(", - ")", - ")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.604434Z", - "iopub.status.busy": "2026-03-06T11:51:29.604359Z", - "iopub.status.idle": "2026-03-06T11:51:29.680947Z", - "shell.execute_reply": "2026-03-06T11:51:29.680667Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.604427Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.084629Z", - "start_time": "2026-04-01T10:19:44.026059Z" - } - }, - "source": [ - "m2.solve(reformulate_sos=\"auto\");" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.681833Z", - "iopub.status.busy": "2026-03-06T11:51:29.681725Z", - "iopub.status.idle": "2026-03-06T11:51:29.698558Z", - "shell.execute_reply": "2026-03-06T11:51:29.698011Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.681822Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.093236Z", - "start_time": "2026-04-01T10:19:44.088898Z" - } - }, - "source": [ - "m2.solution[[\"power\", \"fuel\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.699350Z", - "iopub.status.busy": "2026-03-06T11:51:29.699116Z", - "iopub.status.idle": "2026-03-06T11:51:29.852000Z", - "shell.execute_reply": "2026-03-06T11:51:29.851741Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.699334Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.182075Z", - "start_time": "2026-04-01T10:19:44.103633Z" - } - }, - "source": [ - "bp2 = linopy.breakpoints({\"power\": x_pts2.values, \"fuel\": y_pts2.values}, dim=\"var\")\n", - "plot_pwl_results(m2, bp2, demand2, color=\"C1\")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 3. Disjunctive formulation — 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", - "**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", - "\n", - "The disjunctive formulation is selected automatically when the breakpoint\n", - "arrays have a segment dimension (created by `linopy.segments()`)." - ] - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.852397Z", - "iopub.status.busy": "2026-03-06T11:51:29.852305Z", - "iopub.status.idle": "2026-03-06T11:51:29.866500Z", - "shell.execute_reply": "2026-03-06T11:51:29.866141Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.852387Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.195935Z", - "start_time": "2026-04-01T10:19:44.191939Z" - } - }, - "source": [ - "# x-breakpoints define where each segment lives on the power axis\n", - "# y-breakpoints define the corresponding cost values\n", - "x_seg = linopy.segments([(0, 0), (50, 80)])\n", - "y_seg = linopy.segments([(0, 0), (125, 200)])\n", - "print(\"x segments:\\n\", x_seg.to_pandas())\n", - "print(\"y segments:\\n\", y_seg.to_pandas())" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.866940Z", - "iopub.status.busy": "2026-03-06T11:51:29.866839Z", - "iopub.status.idle": "2026-03-06T11:51:29.955272Z", - "shell.execute_reply": "2026-03-06T11:51:29.954810Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.866931Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.261526Z", - "start_time": "2026-04-01T10:19:44.204505Z" - } - }, - "source": [ - "m", - "3", - " ", - "=", - " ", - "l", - "i", - "n", - "o", - "p", - "y", - ".", - "M", - "o", - "d", - "e", - "l", - "(", - ")", - "\n", - "\n", - "p", - "o", - "w", - "e", - "r", - " ", - "=", - " ", - "m", - "3", - ".", - "a", - "d", - "d", - "_", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - "s", - "(", - "n", - "a", - "m", - "e", - "=", - "\"", - "p", - "o", - "w", - "e", - "r", - "\"", - ",", - " ", - "l", - "o", - "w", - "e", - "r", - "=", - "0", - ",", - " ", - "u", - "p", - "p", - "e", - "r", - "=", - "8", - "0", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "c", - "o", - "s", - "t", - " ", - "=", - " ", - "m", - "3", - ".", - "a", - "d", - "d", - "_", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - "s", - "(", - "n", - "a", - "m", - "e", - "=", - "\"", - "c", - "o", - "s", - "t", - "\"", - ",", - " ", - "l", - "o", - "w", - "e", - "r", - "=", - "0", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "b", - "a", - "c", - "k", - "u", - "p", - " ", - "=", - " ", - "m", - "3", - ".", - "a", - "d", - "d", - "_", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - "s", - "(", - "n", - "a", - "m", - "e", - "=", - "\"", - "b", - "a", - "c", - "k", - "u", - "p", - "\"", - ",", - " ", - "l", - "o", - "w", - "e", - "r", - "=", - "0", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "\n", - "m", - "3", - ".", - "a", - "d", - "d", - "_", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - "_", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - "(", - "\n", - " ", - " ", - " ", - " ", - "(", - "p", - "o", - "w", - "e", - "r", - ",", - " ", - "x", - "_", - "s", - "e", - "g", - ")", - ",", - "\n", - " ", - " ", - " ", - " ", - "(", - "c", - "o", - "s", - "t", - ",", - " ", - "y", - "_", - "s", - "e", - "g", - ")", - ",", - "\n", - " ", - " ", - " ", - " ", - "n", - "a", - "m", - "e", - "=", - "\"", - "p", - "w", - "l", - "\"", - ",", - "\n", - ")", - "\n", - "\n", - "d", - "e", - "m", - "a", - "n", - "d", - "3", - " ", - "=", - " ", - "x", - "r", - ".", - "D", - "a", - "t", - "a", - "A", - "r", - "r", - "a", - "y", - "(", - "[", - "1", - "0", - ",", - " ", - "7", - "0", - ",", - " ", - "9", - "0", - "]", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "m", - "3", - ".", - "a", - "d", - "d", - "_", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - "(", - "p", - "o", - "w", - "e", - "r", - " ", - "+", - " ", - "b", - "a", - "c", - "k", - "u", - "p", - " ", - ">", - "=", - " ", - "d", - "e", - "m", - "a", - "n", - "d", - "3", - ",", - " ", - "n", - "a", - "m", - "e", - "=", - "\"", - "d", - "e", - "m", - "a", - "n", - "d", - "\"", - ")", - "\n", - "m", - "3", - ".", - "a", - "d", - "d", - "_", - "o", - "b", - "j", - "e", - "c", - "t", - "i", - "v", - "e", - "(", - "(", - "c", - "o", - "s", - "t", - " ", - "+", - " ", - "1", - "0", - " ", - "*", - " ", - "b", - "a", - "c", - "k", - "u", - "p", - ")", - ".", - "s", - "u", - "m", - "(", - ")", - ")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:29.955750Z", - "iopub.status.busy": "2026-03-06T11:51:29.955667Z", - "iopub.status.idle": "2026-03-06T11:51:30.027311Z", - "shell.execute_reply": "2026-03-06T11:51:30.026945Z", - "shell.execute_reply.started": "2026-03-06T11:51:29.955741Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.323093Z", - "start_time": "2026-04-01T10:19:44.265474Z" - } - }, - "source": [ - "m3.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:30.028114Z", - "iopub.status.busy": "2026-03-06T11:51:30.027864Z", - "iopub.status.idle": "2026-03-06T11:51:30.043138Z", - "shell.execute_reply": "2026-03-06T11:51:30.042813Z", - "shell.execute_reply.started": "2026-03-06T11:51:30.028095Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.332013Z", - "start_time": "2026-04-01T10:19:44.326391Z" - } - }, - "source": [ - "m3.solution[[\"power\", \"cost\", \"backup\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#", - "#", - " ", - "4", - ".", - " ", - "T", - "a", - "n", - "g", - "e", - "n", - "t", - " ", - "l", - "i", - "n", - "e", - "s", - " ", - "—", - " ", - "C", - "o", - "n", - "c", - "a", - "v", - "e", - " ", - "e", - "f", - "f", - "i", - "c", - "i", - "e", - "n", - "c", - "y", - " ", - "b", - "o", - "u", - "n", - "d", - "\n", - "\n", - "W", - "h", - "e", - "n", - " ", - "t", - "h", - "e", - " ", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - " ", - "f", - "u", - "n", - "c", - "t", - "i", - "o", - "n", - " ", - "i", - "s", - " ", - "*", - "*", - "c", - "o", - "n", - "c", - "a", - "v", - "e", - "*", - "*", - " ", - "a", - "n", - "d", - " ", - "w", - "e", - " ", - "w", - "a", - "n", - "t", - " ", - "t", - "o", - " ", - "b", - "o", - "u", - "n", - "d", - " ", - "y", - " ", - "*", - "*", - "a", - "b", - "o", - "v", - "e", - "*", - "*", - "\n", - "(", - "i", - ".", - "e", - ".", - " ", - "`", - "y", - " ", - "<", - "=", - " ", - "f", - "(", - "x", - ")", - "`", - ")", - ",", - " ", - "w", - "e", - " ", - "c", - "a", - "n", - " ", - "u", - "s", - "e", - " ", - "`", - "t", - "a", - "n", - "g", - "e", - "n", - "t", - "_", - "l", - "i", - "n", - "e", - "s", - "`", - " ", - "t", - "o", - " ", - "g", - "e", - "t", - " ", - "p", - "e", - "r", - "-", - "s", - "e", - "g", - "m", - "e", - "n", - "t", - " ", - "l", - "i", - "n", - "e", - "a", - "r", - "\n", - "e", - "x", - "p", - "r", - "e", - "s", - "s", - "i", - "o", - "n", - "s", - " ", - "a", - "n", - "d", - " ", - "a", - "d", - "d", - " ", - "t", - "h", - "e", - "m", - " ", - "a", - "s", - " ", - "r", - "e", - "g", - "u", - "l", - "a", - "r", - " ", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - " ", - "—", - " ", - "n", - "o", - " ", - "S", - "O", - "S", - "2", - " ", - "o", - "r", - " ", - "b", - "i", - "n", - "a", - "r", - "y", - "\n", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - "s", - " ", - "n", - "e", - "e", - "d", - "e", - "d", - ".", - " ", - "T", - "h", - "i", - "s", - " ", - "i", - "s", - " ", - "t", - "h", - "e", - " ", - "f", - "a", - "s", - "t", - "e", - "s", - "t", - " ", - "t", - "o", - " ", - "s", - "o", - "l", - "v", - "e", - ".", - "\n", - "\n", - "H", - "e", - "r", - "e", - " ", - "w", - "e", - " ", - "b", - "o", - "u", - "n", - "d", - " ", - "f", - "u", - "e", - "l", - " ", - "c", - "o", - "n", - "s", - "u", - "m", - "p", - "t", - "i", - "o", - "n", - " ", - "*", - "b", - "e", - "l", - "o", - "w", - "*", - " ", - "a", - " ", - "c", - "o", - "n", - "c", - "a", - "v", - "e", - " ", - "e", - "f", - "f", - "i", - "c", - "i", - "e", - "n", - "c", - "y", - " ", - "c", - "u", - "r", - "v", - "e", - "." - ] - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:30.043492Z", - "iopub.status.busy": "2026-03-06T11:51:30.043410Z", - "iopub.status.idle": "2026-03-06T11:51:30.113382Z", - "shell.execute_reply": "2026-03-06T11:51:30.112320Z", - "shell.execute_reply.started": "2026-03-06T11:51:30.043484Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.365878Z", - "start_time": "2026-04-01T10:19:44.342206Z" - } - }, - "source": [ - "x", - "_", - "p", - "t", - "s", - "4", - " ", - "=", - " ", - "l", - "i", - "n", - "o", - "p", - "y", - ".", - "b", - "r", - "e", - "a", - "k", - "p", - "o", - "i", - "n", - "t", - "s", - "(", - "[", - "0", - ",", - " ", - "4", - "0", - ",", - " ", - "8", - "0", - ",", - " ", - "1", - "2", - "0", - "]", - ")", - "\n", - "#", - " ", - "C", - "o", - "n", - "c", - "a", - "v", - "e", - " ", - "c", - "u", - "r", - "v", - "e", - ":", - " ", - "d", - "e", - "c", - "r", - "e", - "a", - "s", - "i", - "n", - "g", - " ", - "m", - "a", - "r", - "g", - "i", - "n", - "a", - "l", - " ", - "f", - "u", - "e", - "l", - " ", - "p", - "e", - "r", - " ", - "M", - "W", - "\n", - "y", - "_", - "p", - "t", - "s", - "4", - " ", - "=", - " ", - "l", - "i", - "n", - "o", - "p", - "y", - ".", - "b", - "r", - "e", - "a", - "k", - "p", - "o", - "i", - "n", - "t", - "s", - "(", - "[", - "0", - ",", - " ", - "5", - "0", - ",", - " ", - "9", - "0", - ",", - " ", - "1", - "2", - "0", - "]", - ")", - "\n", - "\n", - "m", - "4", - " ", - "=", - " ", - "l", - "i", - "n", - "o", - "p", - "y", - ".", - "M", - "o", - "d", - "e", - "l", - "(", - ")", - "\n", - "\n", - "p", - "o", - "w", - "e", - "r", - " ", - "=", - " ", - "m", - "4", - ".", - "a", - "d", - "d", - "_", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - "s", - "(", - "n", - "a", - "m", - "e", - "=", - "\"", - "p", - "o", - "w", - "e", - "r", - "\"", - ",", - " ", - "l", - "o", - "w", - "e", - "r", - "=", - "0", - ",", - " ", - "u", - "p", - "p", - "e", - "r", - "=", - "1", - "2", - "0", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "f", - "u", - "e", - "l", - " ", - "=", - " ", - "m", - "4", - ".", - "a", - "d", - "d", - "_", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - "s", - "(", - "n", - "a", - "m", - "e", - "=", - "\"", - "f", - "u", - "e", - "l", - "\"", - ",", - " ", - "l", - "o", - "w", - "e", - "r", - "=", - "0", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "\n", - "#", - " ", - "t", - "a", - "n", - "g", - "e", - "n", - "t", - "_", - "l", - "i", - "n", - "e", - "s", - " ", - "r", - "e", - "t", - "u", - "r", - "n", - "s", - " ", - "o", - "n", - "e", - " ", - "L", - "i", - "n", - "e", - "a", - "r", - "E", - "x", - "p", - "r", - "e", - "s", - "s", - "i", - "o", - "n", - " ", - "p", - "e", - "r", - " ", - "s", - "e", - "g", - "m", - "e", - "n", - "t", - " ", - "—", - " ", - "p", - "u", - "r", - "e", - " ", - "L", - "P", - ",", - " ", - "n", - "o", - " ", - "a", - "u", - "x", - " ", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - "s", - "\n", - "t", - " ", - "=", - " ", - "l", - "i", - "n", - "o", - "p", - "y", - ".", - "t", - "a", - "n", - "g", - "e", - "n", - "t", - "_", - "l", - "i", - "n", - "e", - "s", - "(", - "p", - "o", - "w", - "e", - "r", - ",", - " ", - "x", - "_", - "p", - "t", - "s", - "4", - ",", - " ", - "y", - "_", - "p", - "t", - "s", - "4", - ")", - "\n", - "m", - "4", - ".", - "a", - "d", - "d", - "_", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - "(", - "f", - "u", - "e", - "l", - " ", - "<", - "=", - " ", - "t", - ",", - " ", - "n", - "a", - "m", - "e", - "=", - "\"", - "p", - "w", - "l", - "\"", - ")", - "\n", - "\n", - "d", - "e", - "m", - "a", - "n", - "d", - "4", - " ", - "=", - " ", - "x", - "r", - ".", - "D", - "a", - "t", - "a", - "A", - "r", - "r", - "a", - "y", - "(", - "[", - "3", - "0", - ",", - " ", - "8", - "0", - ",", - " ", - "1", - "0", - "0", - "]", - ",", - " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "m", - "4", - ".", - "a", - "d", - "d", - "_", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - "(", - "p", - "o", - "w", - "e", - "r", - " ", - "=", - "=", - " ", - "d", - "e", - "m", - "a", - "n", - "d", - "4", - ",", - " ", - "n", - "a", - "m", - "e", - "=", - "\"", - "d", - "e", - "m", - "a", - "n", - "d", - "\"", - ")", - "\n", - "#", - " ", - "M", - "a", - "x", - "i", - "m", - "i", - "z", - "e", - " ", - "f", - "u", - "e", - "l", - " ", - "(", - "t", - "o", - " ", - "p", - "u", - "s", - "h", - " ", - "a", - "g", - "a", - "i", - "n", - "s", - "t", - " ", - "t", - "h", - "e", - " ", - "u", - "p", - "p", - "e", - "r", - " ", - "b", - "o", - "u", - "n", - "d", - ")", - "\n", - "m", - "4", - ".", - "a", - "d", - "d", - "_", - "o", - "b", - "j", - "e", - "c", - "t", - "i", - "v", - "e", - "(", - "-", - "f", - "u", - "e", - "l", - ".", - "s", - "u", - "m", - "(", - ")", - ")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:30.113818Z", - "iopub.status.busy": "2026-03-06T11:51:30.113727Z", - "iopub.status.idle": "2026-03-06T11:51:30.171329Z", - "shell.execute_reply": "2026-03-06T11:51:30.170942Z", - "shell.execute_reply.started": "2026-03-06T11:51:30.113810Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.397264Z", - "start_time": "2026-04-01T10:19:44.369422Z" - } - }, - "source": [ - "m4.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:30.172009Z", - "iopub.status.busy": "2026-03-06T11:51:30.171791Z", - "iopub.status.idle": "2026-03-06T11:51:30.191956Z", - "shell.execute_reply": "2026-03-06T11:51:30.191556Z", - "shell.execute_reply.started": "2026-03-06T11:51:30.171993Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.412092Z", - "start_time": "2026-04-01T10:19:44.407213Z" - } - }, - "source": [ - "m4.solution[[\"power\", \"fuel\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:30.192604Z", - "iopub.status.busy": "2026-03-06T11:51:30.192376Z", - "iopub.status.idle": "2026-03-06T11:51:30.345074Z", - "shell.execute_reply": "2026-03-06T11:51:30.344642Z", - "shell.execute_reply.started": "2026-03-06T11:51:30.192590Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.525217Z", - "start_time": "2026-04-01T10:19:44.418513Z" - } - }, - "source": [ - "bp4 = linopy.breakpoints({\"power\": x_pts4.values, \"fuel\": y_pts4.values}, dim=\"var\")\n", - "plot_pwl_results(m4, bp4, demand4, color=\"C4\")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## 5. Slopes mode — 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", - "slopes, x-coordinates, and an initial y-value." - ] - }, - { - "cell_type": "code", - "metadata": { - "execution": { - "iopub.execute_input": "2026-03-06T11:51:30.345523Z", - "iopub.status.busy": "2026-03-06T11:51:30.345404Z", - "iopub.status.idle": "2026-03-06T11:51:30.357312Z", - "shell.execute_reply": "2026-03-06T11:51:30.356954Z", - "shell.execute_reply.started": "2026-03-06T11:51:30.345513Z" - }, - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.558053Z", - "start_time": "2026-04-01T10:19:44.552275Z" - } - }, - "source": [ - "# Marginal costs: $1.1/MW for 0-50, $1.5/MW for 50-100, $1.9/MW for 100-150\n", - "x_pts5 = linopy.breakpoints([0, 50, 100, 150])\n", - "y_pts5 = linopy.breakpoints(slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0)\n", - "print(\"y breakpoints from slopes:\", y_pts5.values)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#", - "#", - " ", - "6", - ".", - " ", - "A", - "c", - "t", - "i", - "v", - "e", - " ", - "p", - "a", - "r", - "a", - "m", - "e", - "t", - "e", - "r", - " ", - "-", - "-", - " ", - "U", - "n", - "i", - "t", - " ", - "c", - "o", - "m", - "m", - "i", - "t", - "m", - "e", - "n", - "t", - " ", - "w", - "i", - "t", - "h", - " ", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - " ", - "e", - "f", - "f", - "i", - "c", - "i", - "e", - "n", - "c", - "y", - "\n", - "\n", - "I", - "n", - " ", - "u", - "n", - "i", - "t", - " ", - "c", - "o", - "m", - "m", - "i", - "t", - "m", - "e", - "n", - "t", - " ", - "p", - "r", - "o", - "b", - "l", - "e", - "m", - "s", - ",", - " ", - "a", - " ", - "b", - "i", - "n", - "a", - "r", - "y", - " ", - "v", - "a", - "r", - "i", - "a", - "b", - "l", - "e", - " ", - "$", - "u", - "_", - "t", - "$", - " ", - "c", - "o", - "n", - "t", - "r", - "o", - "l", - "s", - " ", - "w", - "h", - "e", - "t", - "h", - "e", - "r", - " ", - "a", - "\n", - "u", - "n", - "i", - "t", - " ", - "i", - "s", - " ", - "*", - "*", - "o", - "n", - "*", - "*", - " ", - "o", - "r", - " ", - "*", - "*", - "o", - "f", - "f", - "*", - "*", - ".", - " ", - "W", - "h", - "e", - "n", - " ", - "o", - "f", - "f", - ",", - " ", - "b", - "o", - "t", - "h", - " ", - "p", - "o", - "w", - "e", - "r", - " ", - "o", - "u", - "t", - "p", - "u", - "t", - " ", - "a", - "n", - "d", - " ", - "f", - "u", - "e", - "l", - " ", - "c", - "o", - "n", - "s", - "u", - "m", - "p", - "t", - "i", - "o", - "n", - "\n", - "m", - "u", - "s", - "t", - " ", - "b", - "e", - " ", - "z", - "e", - "r", - "o", - ".", - " ", - "W", - "h", - "e", - "n", - " ", - "o", - "n", - ",", - " ", - "t", - "h", - "e", - " ", - "u", - "n", - "i", - "t", - " ", - "o", - "p", - "e", - "r", - "a", - "t", - "e", - "s", - " ", - "w", - "i", - "t", - "h", - "i", - "n", - " ", - "i", - "t", - "s", - " ", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - "-", - "l", - "i", - "n", - "e", - "a", - "r", - "\n", - "e", - "f", - "f", - "i", - "c", - "i", - "e", - "n", - "c", - "y", - " ", - "c", - "u", - "r", - "v", - "e", - " ", - "b", - "e", - "t", - "w", - "e", - "e", - "n", - " ", - "$", - "P", - "_", - "{", - "m", - "i", - "n", - "}", - "$", - " ", - "a", - "n", - "d", - " ", - "$", - "P", - "_", - "{", - "m", - "a", - "x", - "}", - "$", - ".", - "\n", - "\n", - "T", - "h", - "e", - " ", - "`", - "a", - "c", - "t", - "i", - "v", - "e", - "`", - " ", - "k", - "e", - "y", - "w", - "o", - "r", - "d", - " ", - "o", - "n", - " ", - "`", - "a", - "d", - "d", - "_", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - "_", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - "(", - ")", - "`", - " ", - "h", - "a", - "n", - "d", - "l", - "e", - "s", - " ", - "t", - "h", - "i", - "s", - " ", - "b", - "y", - "\n", - "g", - "a", - "t", - "i", - "n", - "g", - " ", - "t", - "h", - "e", - " ", - "i", - "n", - "t", - "e", - "r", - "n", - "a", - "l", - " ", - "P", - "W", - "L", - " ", - "f", - "o", - "r", - "m", - "u", - "l", - "a", - "t", - "i", - "o", - "n", - " ", - "w", - "i", - "t", - "h", - " ", - "t", - "h", - "e", - " ", - "c", - "o", - "m", - "m", - "i", - "t", - "m", - "e", - "n", - "t", - " ", - "b", - "i", - "n", - "a", - "r", - "y", - ":", - "\n", - "\n", - "-", - " ", - "*", - "*", - "I", - "n", - "c", - "r", - "e", - "m", - "e", - "n", - "t", - "a", - "l", - ":", - "*", - "*", - " ", - "d", - "e", - "l", - "t", - "a", - " ", - "b", - "o", - "u", - "n", - "d", - "s", - " ", - "t", - "i", - "g", - "h", - "t", - "e", - "n", - " ", - "f", - "r", - "o", - "m", - " ", - "$", - "\\", - "d", - "e", - "l", - "t", - "a", - "_", - "i", - " ", - "\\", - "l", - "e", - "q", - " ", - "1", - "$", - " ", - "t", - "o", - "\n", - " ", - " ", - "$", - "\\", - "d", - "e", - "l", - "t", - "a", - "_", - "i", - " ", - "\\", - "l", - "e", - "q", - " ", - "u", - "$", - ",", - " ", - "a", - "n", - "d", - " ", - "b", - "a", - "s", - "e", - " ", - "t", - "e", - "r", - "m", - "s", - " ", - "a", - "r", - "e", - " ", - "m", - "u", - "l", - "t", - "i", - "p", - "l", - "i", - "e", - "d", - " ", - "b", - "y", - " ", - "$", - "u", - "$", - "\n", - "-", - " ", - "*", - "*", - "S", - "O", - "S", - "2", - ":", - "*", - "*", - " ", - "c", - "o", - "n", - "v", - "e", - "x", - "i", - "t", - "y", - " ", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - " ", - "b", - "e", - "c", - "o", - "m", - "e", - "s", - " ", - "$", - "\\", - "s", - "u", - "m", - " ", - "\\", - "l", - "a", - "m", - "b", - "d", - "a", - "_", - "i", - " ", - "=", - " ", - "u", - "$", - "\n", - "-", - " ", - "*", - "*", - "D", - "i", - "s", - "j", - "u", - "n", - "c", - "t", - "i", - "v", - "e", - ":", - "*", - "*", - " ", - "s", - "e", - "g", - "m", - "e", - "n", - "t", - " ", - "s", - "e", - "l", - "e", - "c", - "t", - "i", - "o", - "n", - " ", - "b", - "e", - "c", - "o", - "m", - "e", - "s", - " ", - "$", - "\\", - "s", - "u", - "m", - " ", - "z", - "_", - "k", - " ", - "=", - " ", - "u", - "$", - "\n", - "\n", - "T", - "h", - "i", - "s", - " ", - "i", - "s", - " ", - "t", - "h", - "e", - " ", - "o", - "n", - "l", - "y", - " ", - "g", - "a", - "t", - "i", - "n", - "g", - " ", - "b", - "e", - "h", - "a", - "v", - "i", - "o", - "r", - " ", - "e", - "x", - "p", - "r", - "e", - "s", - "s", - "i", - "b", - "l", - "e", - " ", - "w", - "i", - "t", - "h", - " ", - "p", - "u", - "r", - "e", - " ", - "l", - "i", - "n", - "e", - "a", - "r", - " ", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - ".", - "\n", - "S", - "e", - "l", - "e", - "c", - "t", - "i", - "v", - "e", - "l", - "y", - " ", - "*", - "r", - "e", - "l", - "a", - "x", - "i", - "n", - "g", - "*", - " ", - "t", - "h", - "e", - " ", - "P", - "W", - "L", - " ", - "(", - "l", - "e", - "t", - "t", - "i", - "n", - "g", - " ", - "x", - ",", - " ", - "y", - " ", - "f", - "l", - "o", - "a", - "t", - " ", - "f", - "r", - "e", - "e", - "l", - "y", - " ", - "w", - "h", - "e", - "n", - " ", - "o", - "f", - "f", - ")", - " ", - "w", - "o", - "u", - "l", - "d", - "\n", - "r", - "e", - "q", - "u", - "i", - "r", - "e", - " ", - "b", - "i", - "g", - "-", - "M", - " ", - "o", - "r", - " ", - "i", - "n", - "d", - "i", - "c", - "a", - "t", - "o", - "r", - " ", - "c", - "o", - "n", - "s", - "t", - "r", - "a", - "i", - "n", - "t", - "s", - "." + "ExecuteTime": { + "end_time": "2026-04-01T11:02:26.878327Z", + "start_time": "2026-04-01T11:02:26.872753Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.852397Z", + "iopub.status.busy": "2026-03-06T11:51:29.852305Z", + "iopub.status.idle": "2026-03-06T11:51:29.866500Z", + "shell.execute_reply": "2026-03-06T11:51:29.866141Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.852387Z" + } + }, + "outputs": [], + "source": [ + "# x-breakpoints define where each segment lives on the power axis\n", + "# y-breakpoints define the corresponding cost values\n", + "x_seg = linopy.segments([(0, 0), (50, 80)])\n", + "y_seg = linopy.segments([(0, 0), (125, 200)])\n", + "print(\"x segments:\\n\", x_seg.to_pandas())\n", + "print(\"y segments:\\n\", y_seg.to_pandas())" ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.582188Z", - "start_time": "2026-04-01T10:19:44.576288Z" + "end_time": "2026-04-01T11:02:26.947790Z", + "start_time": "2026-04-01T11:02:26.885620Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.866940Z", + "iopub.status.busy": "2026-03-06T11:51:29.866839Z", + "iopub.status.idle": "2026-03-06T11:51:29.955272Z", + "shell.execute_reply": "2026-03-06T11:51:29.954810Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.866931Z" } }, + "outputs": [], "source": [ - "# Unit parameters: operates between 30-100 MW when on\n", - "p_min, p_max = 30, 100\n", - "fuel_min, fuel_max = 40, 170\n", - "startup_cost = 50\n", + "m3 = linopy.Model()\n", "\n", - "x_pts6 = linopy.breakpoints([p_min, 60, p_max])\n", - "y_pts6 = linopy.breakpoints([fuel_min, 90, fuel_max])\n", - "print(\"Power breakpoints:\", x_pts6.values)\n", - "print(\"Fuel breakpoints: \", y_pts6.values)" - ], + "power = m3.add_variables(name=\"power\", lower=0, upper=80, coords=[time])\n", + "cost = m3.add_variables(name=\"cost\", lower=0, coords=[time])\n", + "backup = m3.add_variables(name=\"backup\", lower=0, coords=[time])\n", + "\n", + "m3.add_piecewise_constraints(\n", + " (power, x_seg),\n", + " (cost, y_seg),\n", + " name=\"pwl\",\n", + ")\n", + "\n", + "demand3 = xr.DataArray([10, 70, 90], coords=[time])\n", + "m3.add_constraints(power + backup >= demand3, name=\"demand\")\n", + "m3.add_objective((cost + 10 * backup).sum())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:27.002220Z", + "start_time": "2026-04-01T11:02:26.953483Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:29.955750Z", + "iopub.status.busy": "2026-03-06T11:51:29.955667Z", + "iopub.status.idle": "2026-03-06T11:51:30.027311Z", + "shell.execute_reply": "2026-03-06T11:51:30.026945Z", + "shell.execute_reply.started": "2026-03-06T11:51:29.955741Z" + } + }, "outputs": [], - "execution_count": null + "source": [ + "m3.solve(reformulate_sos=\"auto\")" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.733423Z", - "start_time": "2026-04-01T10:19:44.620485Z" + "end_time": "2026-04-01T11:02:27.020529Z", + "start_time": "2026-04-01T11:02:27.014185Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:30.028114Z", + "iopub.status.busy": "2026-03-06T11:51:30.027864Z", + "iopub.status.idle": "2026-03-06T11:51:30.043138Z", + "shell.execute_reply": "2026-03-06T11:51:30.042813Z", + "shell.execute_reply.started": "2026-03-06T11:51:30.028095Z" } }, + "outputs": [], "source": [ - "m", - "6", + "m3.solution[[\"power\", \"cost\", \"backup\"]].to_pandas()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#", + "#", " ", - "=", + "4", + ".", " ", - "l", - "i", + "T", + "a", "n", - "o", - "p", - "y", - ".", - "M", - "o", - "d", + "g", "e", + "n", + "t", + " ", "l", - "(", - ")", - "\n", - "\n", - "p", - "o", - "w", + "i", + "n", "e", - "r", + "s", " ", - "=", + "—", " ", - "m", - "6", - ".", + "C", + "o", + "n", + "c", "a", - "d", - "d", - "_", "v", - "a", - "r", + "e", + " ", + "e", + "f", + "f", + "i", + "c", "i", - "a", - "b", - "l", "e", - "s", - "(", "n", - "a", - "m", - "e", - "=", - "\"", - "p", + "c", + "y", + " ", + "b", "o", - "w", + "u", + "n", + "d", + "\n", + "\n", + "W", + "h", "e", - "r", - "\"", - ",", + "n", " ", - "l", - "o", - "w", + "t", + "h", "e", - "r", - "=", - "0", - ",", " ", - "u", - "p", "p", + "i", "e", - "r", - "=", - "p", - "_", - "m", - "a", - "x", - ",", - " ", "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", + "e", + "w", "i", - "m", + "s", "e", - "]", - ")", - "\n", + " ", "f", "u", - "e", - "l", - " ", - "=", + "n", + "c", + "t", + "i", + "o", + "n", " ", - "m", - "6", - ".", - "a", - "d", - "d", - "_", - "v", - "a", - "r", "i", - "a", - "b", - "l", - "e", "s", - "(", + " ", + "*", + "*", + "c", + "o", "n", + "c", "a", - "m", - "e", - "=", - "\"", - "f", - "u", + "v", "e", - "l", - "\"", - ",", + "*", + "*", + " ", + "a", + "n", + "d", " ", - "l", - "o", "w", "e", - "r", - "=", - "0", - ",", " ", - "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", - "e", - "]", - ")", - "\n", - "c", - "o", - "m", - "m", - "i", + "w", + "a", + "n", "t", " ", - "=", + "t", + "o", " ", - "m", - "6", - ".", - "a", - "d", + "b", + "o", + "u", + "n", "d", - "_", - "v", - "a", - "r", - "i", + " ", + "y", + " ", + "*", + "*", "a", "b", - "l", + "o", + "v", "e", - "s", + "*", + "*", + "\n", "(", - "n", - "a", - "m", - "e", - "=", - "\"", - "c", - "o", - "m", - "m", "i", - "t", - "\"", - ",", + ".", + "e", + ".", " ", - "b", - "i", - "n", - "a", - "r", + "`", "y", - "=", - "T", - "r", - "u", - "e", - ",", " ", - "c", - "o", - "o", - "r", - "d", - "s", + "<", "=", - "[", - "t", - "i", - "m", - "e", - "]", + " ", + "f", + "(", + "x", ")", - "\n", - "\n", - "#", + "`", + ")", + ",", " ", - "T", - "h", + "w", "e", " ", - "a", "c", - "t", - "i", - "v", - "e", - " ", - "p", - "a", - "r", "a", - "m", - "e", - "t", + "n", + " ", + "u", + "s", "e", - "r", " ", - "g", + "`", + "t", "a", + "n", + "g", + "e", + "n", "t", + "_", + "l", + "i", + "n", "e", "s", + "`", " ", "t", - "h", - "e", - " ", - "P", - "W", - "L", + "o", " ", - "w", - "i", + "g", + "e", "t", - "h", " ", - "t", - "h", + "p", "e", - " ", - "c", - "o", - "m", - "m", - "i", - "t", + "r", + "-", + "s", + "e", + "g", "m", "e", "n", "t", " ", - "b", + "l", "i", "n", + "e", "a", "r", - "y", - ":", "\n", - "#", - " ", - "-", - " ", - "c", - "o", - "m", - "m", - "i", - "t", - "=", - "1", - ":", - " ", - "p", - "o", - "w", "e", + "x", + "p", "r", - " ", + "e", + "s", + "s", "i", + "o", "n", + "s", " ", - "[", - "3", - "0", - ",", + "a", + "n", + "d", " ", - "1", - "0", - "0", - "]", - ",", + "a", + "d", + "d", " ", - "f", - "u", + "t", + "h", "e", - "l", + "m", " ", - "=", + "a", + "s", " ", - "f", - "(", - "p", - "o", - "w", + "r", "e", + "g", + "u", + "l", + "a", "r", - ")", - "\n", - "#", - " ", - "-", " ", "c", "o", - "m", - "m", - "i", + "n", + "s", "t", - "=", - "0", - ":", - " ", - "p", - "o", - "w", - "e", "r", + "a", + "i", + "n", + "t", + "s", " ", - "=", - " ", - "0", - ",", + "—", " ", - "f", - "u", - "e", - "l", + "n", + "o", " ", - "=", + "S", + "O", + "S", + "2", " ", - "0", - "\n", - "m", - "6", - ".", - "a", - "d", - "d", - "_", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - "_", - "c", "o", + "r", + " ", + "b", + "i", "n", - "s", - "t", + "a", "r", + "y", + "\n", + "v", "a", + "r", "i", - "n", - "t", + "a", + "b", + "l", + "e", "s", - "(", - "\n", - " ", " ", - " ", - " ", - "(", - "p", - "o", - "w", + "n", "e", - "r", - ",", + "e", + "d", + "e", + "d", + ".", " ", - "x", - "_", - "p", - "t", + "T", + "h", + "i", "s", - "6", - ")", - ",", - "\n", - " ", " ", + "i", + "s", " ", + "t", + "h", + "e", " ", - "(", "f", - "u", + "a", + "s", + "t", "e", - "l", - ",", + "s", + "t", " ", - "y", - "_", - "p", "t", + "o", + " ", "s", - "6", - ")", - ",", + "o", + "l", + "v", + "e", + ".", "\n", + "\n", + "H", + "e", + "r", + "e", " ", + "w", + "e", " ", + "b", + "o", + "u", + "n", + "d", " ", - " ", - "a", - "c", - "t", - "i", - "v", + "f", + "u", "e", - "=", + "l", + " ", "c", "o", + "n", + "s", + "u", "m", - "m", - "i", + "p", "t", - ",", - "\n", - " ", + "i", + "o", + "n", " ", + "*", + "b", + "e", + "l", + "o", + "w", + "*", " ", + "a", " ", + "c", + "o", "n", + "c", "a", - "m", + "v", "e", - "=", - "\"", - "p", - "w", - "l", - "\"", - ",", - "\n", - " ", " ", - " ", - " ", - "m", "e", - "t", - "h", - "o", - "d", - "=", - "\"", + "f", + "f", + "i", + "c", "i", + "e", "n", "c", + "y", + " ", + "c", + "u", "r", + "v", "e", - "m", - "e", - "n", - "t", - "a", - "l", - "\"", - ",", + "." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:27.051903Z", + "start_time": "2026-04-01T11:02:27.026742Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:30.043492Z", + "iopub.status.busy": "2026-03-06T11:51:30.043410Z", + "iopub.status.idle": "2026-03-06T11:51:30.113382Z", + "shell.execute_reply": "2026-03-06T11:51:30.112320Z", + "shell.execute_reply.started": "2026-03-06T11:51:30.043484Z" + } + }, + "outputs": [], + "source": [ + "x_pts4 = linopy.breakpoints([0, 40, 80, 120])\n", + "# Concave curve: decreasing marginal fuel per MW\n", + "y_pts4 = linopy.breakpoints([0, 50, 90, 120])\n", "\n", - ")", + "m4 = linopy.Model()\n", "\n", + "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", + "# tangent_lines returns one LinearExpression per segment — pure LP, no aux variables\n", + "t = linopy.tangent_lines(power, x_pts4, y_pts4)\n", + "m4.add_constraints(fuel <= t, name=\"pwl\")\n", + "\n", + "demand4 = xr.DataArray([30, 80, 100], coords=[time])\n", + "m4.add_constraints(power == demand4, name=\"demand\")\n", + "# Maximize fuel (to push against the upper bound)\n", + "m4.add_objective(-fuel.sum())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:27.087177Z", + "start_time": "2026-04-01T11:02:27.056184Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:30.113818Z", + "iopub.status.busy": "2026-03-06T11:51:30.113727Z", + "iopub.status.idle": "2026-03-06T11:51:30.171329Z", + "shell.execute_reply": "2026-03-06T11:51:30.170942Z", + "shell.execute_reply.started": "2026-03-06T11:51:30.113810Z" + } + }, + "outputs": [], + "source": [ + "m4.solve(reformulate_sos=\"auto\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:27.096041Z", + "start_time": "2026-04-01T11:02:27.091468Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:30.172009Z", + "iopub.status.busy": "2026-03-06T11:51:30.171791Z", + "iopub.status.idle": "2026-03-06T11:51:30.191956Z", + "shell.execute_reply": "2026-03-06T11:51:30.191556Z", + "shell.execute_reply.started": "2026-03-06T11:51:30.171993Z" + } + }, + "outputs": [], + "source": [ + "m4.solution[[\"power\", \"fuel\"]].to_pandas()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:27.193996Z", + "start_time": "2026-04-01T11:02:27.113630Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:30.192604Z", + "iopub.status.busy": "2026-03-06T11:51:30.192376Z", + "iopub.status.idle": "2026-03-06T11:51:30.345074Z", + "shell.execute_reply": "2026-03-06T11:51:30.344642Z", + "shell.execute_reply.started": "2026-03-06T11:51:30.192590Z" + } + }, + "outputs": [], + "source": [ + "bp4 = linopy.breakpoints({\"power\": x_pts4.values, \"fuel\": y_pts4.values}, dim=\"var\")\n", + "plot_pwl_results(m4, bp4, demand4, color=\"C4\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Slopes mode — 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", + "slopes, x-coordinates, and an initial y-value." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:27.207526Z", + "start_time": "2026-04-01T11:02:27.204734Z" + }, + "execution": { + "iopub.execute_input": "2026-03-06T11:51:30.345523Z", + "iopub.status.busy": "2026-03-06T11:51:30.345404Z", + "iopub.status.idle": "2026-03-06T11:51:30.357312Z", + "shell.execute_reply": "2026-03-06T11:51:30.356954Z", + "shell.execute_reply.started": "2026-03-06T11:51:30.345513Z" + } + }, + "outputs": [], + "source": [ + "# Marginal costs: $1.1/MW for 0-50, $1.5/MW for 50-100, $1.9/MW for 100-150\n", + "x_pts5 = linopy.breakpoints([0, 50, 100, 150])\n", + "y_pts5 = linopy.breakpoints(slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0)\n", + "print(\"y breakpoints from slopes:\", y_pts5.values)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#", "#", " ", - "D", - "e", - "m", - "a", - "n", - "d", - ":", - " ", - "l", - "o", - "w", - " ", - "a", - "t", - " ", - "t", - "=", - "1", - " ", - "(", - "c", - "h", - "e", - "a", - "p", - "e", - "r", - " ", - "t", - "o", - " ", - "s", - "t", - "a", - "y", - " ", - "o", - "f", - "f", - ")", - ",", - " ", - "h", - "i", - "g", - "h", - " ", - "a", - "t", - " ", - "t", - "=", - "2", - ",", - "3", - "\n", - "d", - "e", - "m", - "a", - "n", - "d", "6", - " ", - "=", - " ", - "x", - "r", ".", - "D", - "a", - "t", - "a", - "A", - "r", - "r", - "a", - "y", - "(", - "[", - "1", - "5", - ",", - " ", - "7", - "0", - ",", - " ", - "5", - "0", - "]", - ",", " ", + "A", "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", "t", "i", - "m", + "v", "e", - "]", - ")", - "\n", - "b", - "a", - "c", - "k", - "u", - "p", " ", - "=", - " ", - "m", - "6", - ".", - "a", - "d", - "d", - "_", - "v", + "p", "a", "r", - "i", - "a", - "b", - "l", - "e", - "s", - "(", - "n", "a", "m", "e", - "=", - "\"", - "b", - "a", - "c", - "k", - "u", - "p", - "\"", - ",", - " ", - "l", - "o", - "w", + "t", "e", "r", - "=", - "0", - ",", + " ", + "-", + "-", + " ", + "U", + "n", + "i", + "t", " ", "c", "o", - "o", - "r", - "d", - "s", - "=", - "[", - "t", + "m", + "m", "i", + "t", "m", "e", - "]", - ")", - "\n", - "m", - "6", - ".", - "a", - "d", - "d", - "_", - "c", - "o", "n", - "s", "t", - "r", - "a", + " ", + "w", "i", - "n", "t", - "s", - "(", - "p", - "o", - "w", - "e", - "r", - " ", - "+", + "h", " ", - "b", - "a", - "c", - "k", - "u", "p", - " ", - ">", - "=", - " ", - "d", + "i", "e", - "m", - "a", - "n", - "d", - "6", - ",", - " ", - "n", - "a", - "m", + "c", "e", - "=", - "\"", - "d", + "w", + "i", + "s", "e", - "m", - "a", - "n", - "d", - "\"", - ")", - "\n", - "\n", - "#", " ", - "O", - "b", - "j", "e", + "f", + "f", + "i", "c", - "t", "i", - "v", "e", - ":", + "n", + "c", + "y", + "\n", + "\n", + "I", + "n", " ", - "f", "u", - "e", - "l", - " ", - "+", + "n", + "i", + "t", " ", - "s", + "c", + "o", + "m", + "m", + "i", "t", - "a", - "r", + "m", + "e", + "n", "t", - "u", - "p", " ", - "c", + "p", + "r", "o", + "b", + "l", + "e", + "m", "s", - "t", + ",", " ", - "+", + "a", " ", "b", + "i", + "n", "a", - "c", - "k", - "u", - "p", + "r", + "y", " ", + "v", "a", - "t", - " ", - "$", - "5", - "/", - "M", - "W", - "\n", - "m", - "6", - ".", + "r", + "i", "a", - "d", - "d", - "_", - "o", "b", - "j", - "e", - "c", - "t", - "i", - "v", - "e", - "(", - "(", - "f", - "u", - "e", "l", + "e", " ", - "+", - " ", - "s", - "t", - "a", - "r", - "t", + "$", "u", - "p", "_", - "c", - "o", - "s", "t", - " ", - "*", + "$", " ", "c", "o", - "m", - "m", - "i", + "n", "t", + "r", + "o", + "l", + "s", " ", - "+", - " ", - "5", - " ", - "*", + "w", + "h", + "e", + "t", + "h", + "e", + "r", " ", - "b", "a", - "c", - "k", - "u", - "p", - ")", - ".", - "s", + "\n", "u", - "m", - "(", - ")", - ")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.802729Z", - "start_time": "2026-04-01T10:19:44.735824Z" - } - }, - "source": [ - "m6.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.830080Z", - "start_time": "2026-04-01T10:19:44.822947Z" - } - }, - "source": [ - "m6.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "code", - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.952553Z", - "start_time": "2026-04-01T10:19:44.836547Z" - } - }, - "source": [ - "bp6 = linopy.breakpoints({\"power\": x_pts6.values, \"fuel\": y_pts6.values}, dim=\"var\")\n", - "plot_pwl_results(m6, bp6, demand6, color=\"C2\")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "A", + "n", + "i", "t", " ", + "i", + "s", + " ", "*", "*", - "t", - "=", - "1", + "o", + "n", "*", "*", - ",", " ", - "d", - "e", - "m", - "a", - "n", - "d", + "o", + "r", " ", - "(", - "1", - "5", + "*", + "*", + "o", + "f", + "f", + "*", + "*", + ".", " ", - "M", "W", - ")", + "h", + "e", + "n", " ", - "i", - "s", + "o", + "f", + "f", + ",", " ", "b", - "e", - "l", + "o", + "t", + "h", + " ", + "p", "o", "w", + "e", + "r", " ", + "o", + "u", "t", - "h", + "p", + "u", + "t", + " ", + "a", + "n", + "d", + " ", + "f", + "u", "e", + "l", " ", + "c", + "o", + "n", + "s", + "u", "m", + "p", + "t", "i", + "o", "n", - "i", + "\n", "m", "u", - "m", + "s", + "t", " ", - "l", - "o", - "a", - "d", + "b", + "e", " ", - "(", - "3", - "0", + "z", + "e", + "r", + "o", + ".", " ", - "M", "W", - ")", - ".", + "h", + "e", + "n", " ", - "T", + "o", + "n", + ",", + " ", + "t", "h", "e", " ", - "s", + "u", + "n", + "i", + "t", + " ", "o", - "l", - "v", + "p", "e", "r", - "\n", - "k", - "e", + "a", + "t", "e", - "p", "s", " ", + "w", + "i", "t", "h", - "e", - " ", - "u", + "i", "n", + " ", "i", "t", + "s", " ", - "o", + "p", + "i", + "e", + "c", + "e", + "w", + "i", + "s", + "e", + "-", + "l", + "i", + "n", + "e", + "a", + "r", + "\n", + "e", "f", "f", - " ", - "(", - "`", + "i", "c", - "o", - "m", - "m", "i", - "t", - "=", - "0", - "`", - ")", - ",", + "e", + "n", + "c", + "y", " ", - "s", - "o", + "c", + "u", + "r", + "v", + "e", " ", - "`", - "p", - "o", + "b", + "e", + "t", "w", "e", - "r", - "=", - "0", - "`", + "e", + "n", + " ", + "$", + "P", + "_", + "{", + "m", + "i", + "n", + "}", + "$", " ", "a", "n", "d", " ", - "`", - "f", - "u", - "e", - "l", - "=", - "0", - "`", - " ", - "—", - " ", - "t", + "$", + "P", + "_", + "{", + "m", + "a", + "x", + "}", + "$", + ".", + "\n", + "\n", + "T", "h", "e", " ", @@ -5935,807 +1370,964 @@ "v", "e", "`", - "\n", - "p", - "a", - "r", - "a", - "m", - "e", - "t", - "e", - "r", " ", + "k", "e", - "n", - "f", + "y", + "w", "o", "r", + "d", + " ", + "o", + "n", + " ", + "`", + "a", + "d", + "d", + "_", + "p", + "i", + "e", "c", "e", + "w", + "i", + "s", + "e", + "_", + "c", + "o", + "n", "s", - " ", "t", - "h", + "r", + "a", "i", + "n", + "t", "s", - ".", + "(", + ")", + "`", " ", - "D", - "e", - "m", + "h", "a", "n", "d", - " ", - "i", + "l", + "e", "s", " ", - "m", - "e", "t", + "h", + "i", + "s", " ", "b", "y", + "\n", + "g", + "a", + "t", + "i", + "n", + "g", " ", "t", "h", "e", " ", - "b", + "i", + "n", + "t", + "e", + "r", + "n", "a", - "c", - "k", - "u", - "p", + "l", " ", - "s", + "P", + "W", + "L", + " ", + "f", "o", - "u", "r", - "c", - "e", - ".", - "\n", - "\n", - "A", - "t", - " ", - "*", - "*", - "t", - "=", - "2", - "*", - "*", - " ", + "m", + "u", + "l", "a", + "t", + "i", + "o", "n", - "d", " ", - "*", - "*", + "w", + "i", "t", - "=", - "3", - "*", - "*", - ",", + "h", " ", "t", "h", "e", " ", - "u", - "n", - "i", - "t", - " ", "c", "o", "m", "m", "i", "t", - "s", + "m", + "e", + "n", + "t", + " ", + "b", + "i", + "n", + "a", + "r", + "y", + ":", + "\n", + "\n", + "-", + " ", + "*", + "*", + "I", + "n", + "c", + "r", + "e", + "m", + "e", + "n", + "t", + "a", + "l", + ":", + "*", + "*", " ", - "a", - "n", "d", - " ", - "o", - "p", "e", - "r", - "a", + "l", "t", - "e", - "s", + "a", " ", + "b", "o", + "u", "n", + "d", + "s", " ", "t", - "h", - "e", - " ", - "P", - "W", - "L", - " ", - "c", - "u", - "r", - "v", - "e", - "." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#", - "#", - " ", - "7", - ".", - " ", - "N", - "-", - "v", - "a", - "r", "i", - "a", - "b", - "l", + "g", + "h", + "t", "e", + "n", " ", "f", - "o", "r", + "o", "m", - "u", + " ", + "$", + "\\", + "d", + "e", "l", - "a", "t", + "a", + "_", "i", - "o", - "n", " ", - "-", - "-", + "\\", + "l", + "e", + "q", " ", - "C", - "H", - "P", + "1", + "$", " ", - "p", - "l", - "a", - "n", "t", + "o", "\n", - "\n", - "W", - "h", - "e", - "n", " ", - "m", - "u", + " ", + "$", + "\\", + "d", + "e", "l", "t", + "a", + "_", "i", - "p", + " ", + "\\", "l", "e", + "q", " ", - "o", "u", - "t", - "p", - "u", - "t", - "s", + "$", + ",", " ", "a", - "r", - "e", - " ", - "l", - "i", "n", - "k", - "e", "d", " ", + "b", + "a", + "s", + "e", + " ", "t", - "h", + "e", "r", - "o", - "u", - "g", - "h", - " ", + "m", "s", - "h", + " ", "a", "r", "e", - "d", " ", - "o", - "p", - "e", - "r", - "a", + "m", + "u", + "l", "t", "i", - "n", - "g", - " ", "p", - "o", + "l", "i", - "n", - "t", - "s", - " ", - "(", "e", - ".", - "g", - ".", - ",", + "d", " ", - "a", + "b", + "y", + " ", + "$", + "u", + "$", "\n", + "-", + " ", + "*", + "*", + "S", + "O", + "S", + "2", + ":", + "*", + "*", + " ", "c", "o", - "m", - "b", - "i", "n", + "v", "e", - "d", - " ", - "h", - "e", - "a", + "x", + "i", "t", + "y", " ", - "a", - "n", - "d", - " ", - "p", + "c", "o", - "w", - "e", + "n", + "s", + "t", "r", - " ", - "p", - "l", "a", + "i", "n", "t", " ", - "w", - "h", - "e", - "r", + "b", "e", - " ", - "p", + "c", "o", - "w", + "m", "e", - "r", - ",", + "s", " ", - "f", + "$", + "\\", + "s", "u", - "e", - "l", - ",", + "m", " ", + "\\", + "l", "a", - "n", + "m", + "b", "d", + "a", + "_", + "i", " ", - "h", + "=", + " ", + "u", + "$", + "\n", + "-", + " ", + "*", + "*", + "D", + "i", + "s", + "j", + "u", + "n", + "c", + "t", + "i", + "v", + "e", + ":", + "*", + "*", + " ", + "s", + "e", + "g", + "m", "e", - "a", + "n", "t", " ", - "a", - "r", + "s", "e", - " ", - "a", "l", - "l", - " ", - "f", - "u", - "n", + "e", "c", "t", "i", "o", "n", - "s", - "\n", + " ", + "b", + "e", + "c", "o", - "f", + "m", + "e", + "s", " ", - "a", + "$", + "\\", + "s", + "u", + "m", + " ", + "z", + "_", + "k", + " ", + "=", " ", + "u", + "$", + "\n", + "\n", + "T", + "h", + "i", "s", + " ", "i", - "n", - "g", - "l", + "s", + " ", + "t", + "h", "e", " ", - "l", "o", + "n", + "l", + "y", + " ", + "g", "a", - "d", + "t", "i", "n", "g", " ", - "p", + "b", + "e", + "h", "a", + "v", + "i", + "o", "r", - "a", - "m", - "e", - "t", + " ", "e", + "x", + "p", "r", - ")", - ",", - " ", - "u", + "e", "s", + "s", + "i", + "b", + "l", "e", " ", + "w", + "i", "t", "h", - "e", " ", - "*", - "*", - "N", - "-", - "v", - "a", + "p", + "u", "r", - "i", - "a", - "b", - "l", "e", - "*", - "*", " ", - "A", - "P", - "I", - ".", - "\n", - "\n", - "I", + "l", + "i", "n", - "s", - "t", "e", "a", - "d", + "r", " ", + "c", "o", - "f", - " ", + "n", "s", - "e", - "p", - "a", - "r", - "a", "t", - "e", - " ", - "x", - "/", - "y", - " ", - "b", "r", - "e", "a", - "k", - "p", - "o", "i", "n", "t", "s", - ",", - " ", - "y", - "o", - "u", - " ", - "p", - "a", - "s", - "s", - " ", - "a", - " ", - "d", - "i", + ".", + "\n", + "S", + "e", + "l", + "e", "c", "t", "i", - "o", - "n", - "a", - "r", + "v", + "e", + "l", "y", " ", - "o", - "f", - " ", - "e", - "x", - "p", + "*", "r", "e", - "s", - "s", - "i", - "o", - "n", - "s", - "\n", - "a", - "n", - "d", - " ", + "l", "a", - " ", - "s", + "x", "i", "n", "g", - "l", + "*", + " ", + "t", + "h", "e", " ", - "b", - "r", + "P", + "W", + "L", + " ", + "(", + "l", "e", - "a", - "k", - "p", - "o", + "t", + "t", "i", "n", - "t", + "g", " ", - "D", + "x", + ",", + " ", + "y", + " ", + "f", + "l", + "o", "a", "t", - "a", - "A", - "r", + " ", + "f", "r", - "a", + "e", + "e", + "l", "y", " ", "w", "h", - "o", - "s", "e", + "n", " ", - "c", "o", + "f", + "f", + ")", + " ", + "w", "o", - "r", + "u", + "l", "d", + "\n", + "r", + "e", + "q", + "u", "i", - "n", - "a", - "t", + "r", "e", - "s", " ", - "m", - "a", - "t", - "c", - "h", + "b", + "i", + "g", + "-", + "M", " ", - "t", - "h", - "e", + "o", + "r", " ", + "i", + "n", "d", "i", "c", + "a", "t", - "i", "o", - "n", - "a", "r", - "y", " ", - "k", - "e", - "y", + "c", + "o", + "n", + "s", + "t", + "r", + "a", + "i", + "n", + "t", "s", "." ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T10:19:44.983662Z", - "start_time": "2026-04-01T10:19:44.966612Z" + "end_time": "2026-04-01T11:02:27.220030Z", + "start_time": "2026-04-01T11:02:27.217629Z" } }, + "outputs": [], "source": [ - "# CHP operating points: as load increases, power, fuel, and heat all change\n", - "bp_chp = linopy.breakpoints(\n", - " {\n", - " \"power\": [0, 30, 60, 100],\n", - " \"fuel\": [0, 40, 85, 160],\n", - " \"heat\": [0, 25, 55, 95],\n", - " },\n", - " dim=\"var\",\n", + "# Unit parameters: operates between 30-100 MW when on\n", + "p_min, p_max = 30, 100\n", + "fuel_min, fuel_max = 40, 170\n", + "startup_cost = 50\n", + "\n", + "x_pts6 = linopy.breakpoints([p_min, 60, p_max])\n", + "y_pts6 = linopy.breakpoints([fuel_min, 90, fuel_max])\n", + "print(\"Power breakpoints:\", x_pts6.values)\n", + "print(\"Fuel breakpoints: \", y_pts6.values)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:27.315829Z", + "start_time": "2026-04-01T11:02:27.233033Z" + } + }, + "outputs": [], + "source": [ + "m6 = linopy.Model()\n", + "\n", + "power = m6.add_variables(name=\"power\", lower=0, upper=p_max, coords=[time])\n", + "fuel = m6.add_variables(name=\"fuel\", lower=0, coords=[time])\n", + "commit = m6.add_variables(name=\"commit\", binary=True, coords=[time])\n", + "\n", + "# The active parameter gates the PWL with the commitment binary:\n", + "# - commit=1: power in [30, 100], fuel = f(power)\n", + "# - commit=0: power = 0, fuel = 0\n", + "m6.add_piecewise_constraints(\n", + " (power, x_pts6),\n", + " (fuel, y_pts6),\n", + " active=commit,\n", + " name=\"pwl\",\n", + " method=\"incremental\",\n", ")\n", - "print(\"CHP breakpoints:\")\n", - "print(bp_chp.to_pandas())" - ], + "\n", + "# Demand: low at t=1 (cheaper to stay off), high at t=2,3\n", + "demand6 = xr.DataArray([15, 70, 50], coords=[time])\n", + "backup = m6.add_variables(name=\"backup\", lower=0, coords=[time])\n", + "m6.add_constraints(power + backup >= demand6, name=\"demand\")\n", + "\n", + "# Objective: fuel + startup cost + backup at $5/MW\n", + "m6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:27.384451Z", + "start_time": "2026-04-01T11:02:27.320144Z" + } + }, + "outputs": [], + "source": [ + "m6.solve(reformulate_sos=\"auto\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:27.402559Z", + "start_time": "2026-04-01T11:02:27.394836Z" + } + }, "outputs": [], - "execution_count": null + "source": [ + "m6.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T10:19:45.084851Z", - "start_time": "2026-04-01T10:19:44.996249Z" + "end_time": "2026-04-01T11:02:27.498992Z", + "start_time": "2026-04-01T11:02:27.412130Z" } }, + "outputs": [], "source": [ - "m", - "7", + "bp6 = linopy.breakpoints({\"power\": x_pts6.values, \"fuel\": y_pts6.values}, dim=\"var\")\n", + "plot_pwl_results(m6, bp6, demand6, color=\"C2\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A", + "t", " ", + "*", + "*", + "t", "=", + "1", + "*", + "*", + ",", " ", - "l", - "i", - "n", - "o", - "p", - "y", - ".", - "M", - "o", "d", "e", - "l", + "m", + "a", + "n", + "d", + " ", "(", + "1", + "5", + " ", + "M", + "W", ")", - "\n", - "\n", - "p", + " ", + "i", + "s", + " ", + "b", + "e", + "l", "o", "w", - "e", - "r", " ", - "=", + "t", + "h", + "e", " ", "m", - "7", - ".", + "i", + "n", + "i", + "m", + "u", + "m", + " ", + "l", + "o", "a", "d", - "d", - "_", + " ", + "(", + "3", + "0", + " ", + "M", + "W", + ")", + ".", + " ", + "T", + "h", + "e", + " ", + "s", + "o", + "l", "v", - "a", + "e", "r", - "i", - "a", - "b", - "l", + "\n", + "k", "e", + "e", + "p", "s", - "(", + " ", + "t", + "h", + "e", + " ", + "u", "n", - "a", + "i", + "t", + " ", + "o", + "f", + "f", + " ", + "(", + "`", + "c", + "o", "m", - "e", + "m", + "i", + "t", "=", - "\"", - "p", - "o", - "w", - "e", - "r", - "\"", + "0", + "`", + ")", ",", " ", - "l", + "s", + "o", + " ", + "`", + "p", "o", "w", "e", "r", "=", "0", - ",", + "`", + " ", + "a", + "n", + "d", " ", + "`", + "f", "u", - "p", - "p", "e", - "r", + "l", "=", - "1", "0", - "0", - ",", + "`", + " ", + "—", + " ", + "t", + "h", + "e", " ", + "`", + "a", "c", - "o", - "o", - "r", - "d", - "s", - "=", - "[", "t", "i", - "m", + "v", "e", - "]", - ")", + "`", "\n", + "p", + "a", + "r", + "a", + "m", + "e", + "t", + "e", + "r", + " ", + "e", + "n", "f", - "u", + "o", + "r", + "c", "e", - "l", + "s", " ", - "=", + "t", + "h", + "i", + "s", + ".", " ", + "D", + "e", "m", - "7", - ".", "a", + "n", "d", - "d", - "_", - "v", - "a", - "r", + " ", "i", - "a", - "b", - "l", - "e", "s", - "(", - "n", - "a", + " ", "m", "e", - "=", - "\"", - "f", - "u", - "e", - "l", - "\"", - ",", + "t", " ", - "l", - "o", - "w", + "b", + "y", + " ", + "t", + "h", "e", - "r", - "=", - "0", - ",", " ", + "b", + "a", "c", + "k", + "u", + "p", + " ", + "s", "o", - "o", + "u", "r", + "c", + "e", + ".", + "\n", + "\n", + "A", + "t", + " ", + "*", + "*", + "t", + "=", + "2", + "*", + "*", + " ", + "a", + "n", "d", - "s", + " ", + "*", + "*", + "t", "=", - "[", + "3", + "*", + "*", + ",", + " ", "t", - "i", - "m", - "e", - "]", - ")", - "\n", "h", "e", - "a", - "t", " ", - "=", + "u", + "n", + "i", + "t", " ", + "c", + "o", "m", - "7", - ".", + "m", + "i", + "t", + "s", + " ", "a", + "n", "d", - "d", - "_", - "v", - "a", + " ", + "o", + "p", + "e", "r", - "i", "a", - "b", - "l", + "t", "e", "s", - "(", + " ", + "o", "n", - "a", - "m", - "e", - "=", - "\"", + " ", + "t", "h", "e", - "a", - "t", - "\"", - ",", " ", - "l", - "o", - "w", - "e", - "r", - "=", - "0", - ",", + "P", + "W", + "L", " ", "c", - "o", - "o", + "u", "r", - "d", - "s", - "=", - "[", - "t", - "i", - "m", + "v", "e", - "]", - ")", - "\n", - "\n", + "." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#", "#", " ", + "7", + ".", + " ", "N", "-", "v", @@ -6746,17 +2338,58 @@ "b", "l", "e", - ":", " ", + "f", + "o", + "r", + "m", + "u", + "l", + "a", + "t", + "i", + "o", + "n", + " ", + "-", + "-", + " ", + "C", + "H", + "P", + " ", + "p", + "l", "a", + "n", + "t", + "\n", + "\n", + "W", + "h", + "e", + "n", + " ", + "m", + "u", "l", + "t", + "i", + "p", "l", + "e", " ", + "o", + "u", "t", - "h", + "p", + "u", + "t", + "s", + " ", + "a", "r", "e", - "e", " ", "l", "i", @@ -6780,303 +2413,267 @@ "e", "d", " ", - "i", - "n", - "t", + "o", + "p", "e", "r", - "p", - "o", - "l", "a", "t", "i", - "o", - "n", - " ", - "w", - "e", - "i", - "g", - "h", - "t", - "s", - "\n", - "m", - "7", - ".", - "a", - "d", - "d", - "_", - "p", - "i", - "e", - "c", - "e", - "w", - "i", - "s", - "e", - "_", - "c", - "o", "n", - "s", - "t", - "r", - "a", + "g", + " ", + "p", + "o", "i", "n", "t", "s", + " ", "(", - "\n", + "e", + ".", + "g", + ".", + ",", " ", + "a", + "\n", + "c", + "o", + "m", + "b", + "i", + "n", + "e", + "d", " ", + "h", + "e", + "a", + "t", " ", + "a", + "n", + "d", " ", - "(", "p", "o", "w", "e", "r", - ",", " ", - "b", - "p", - "_", - "c", - "h", "p", - ".", - "s", - "e", "l", - "(", - "v", "a", + "n", + "t", + " ", + "w", + "h", + "e", "r", - "=", - "\"", + "e", + " ", "p", "o", "w", "e", "r", - "\"", - ")", - ")", ",", - "\n", " ", - " ", - " ", - " ", - "(", "f", "u", "e", "l", ",", " ", - "b", - "p", - "_", - "c", - "h", - "p", - ".", - "s", - "e", - "l", - "(", - "v", "a", - "r", - "=", - "\"", - "f", - "u", - "e", - "l", - "\"", - ")", - ")", - ",", - "\n", - " ", - " ", - " ", + "n", + "d", " ", - "(", "h", "e", "a", "t", - ",", " ", - "b", - "p", - "_", - "c", - "h", - "p", - ".", - "s", - "e", - "l", - "(", - "v", "a", "r", - "=", - "\"", - "h", "e", - "a", - "t", - "\"", - ")", - ")", - ",", - "\n", - " ", - " ", " ", + "a", + "l", + "l", " ", + "f", + "u", "n", - "a", - "m", - "e", - "=", - "\"", "c", - "h", - "p", - "\"", - ",", - "\n", - " ", - " ", - " ", - " ", - "m", - "e", "t", - "h", - "o", - "d", - "=", - "\"", - "s", + "i", "o", + "n", "s", - "2", - "\"", - ",", - "\n", - ")", - "\n", "\n", - "#", + "o", + "f", + " ", + "a", " ", - "F", + "s", "i", - "x", + "n", + "g", + "l", "e", - "d", " ", - "p", + "l", "o", - "w", - "e", - "r", - " ", + "a", "d", "i", - "s", + "n", + "g", + " ", "p", "a", + "r", + "a", + "m", + "e", "t", - "c", - "h", + "e", + "r", + ")", + ",", " ", - "d", + "u", + "s", "e", + " ", "t", + "h", "e", + " ", + "*", + "*", + "N", + "-", + "v", + "a", "r", - "m", "i", - "n", + "a", + "b", + "l", "e", - "s", + "*", + "*", " ", + "A", + "P", + "I", + ".", + "\n", + "\n", + "I", + "n", + "s", "t", - "h", "e", + "a", + "d", " ", "o", - "p", + "f", + " ", + "s", "e", + "p", + "a", "r", "a", "t", - "i", - "n", - "g", + "e", + " ", + "x", + "/", + "y", " ", + "b", + "r", + "e", + "a", + "k", "p", "o", "i", "n", "t", + "s", + ",", " ", - "—", + "y", + "o", + "u", + " ", + "p", + "a", + "s", + "s", + " ", + "a", + " ", + "d", + "i", + "c", + "t", + "i", + "o", + "n", + "a", + "r", + "y", " ", + "o", "f", - "u", - "e", - "l", " ", + "e", + "x", + "p", + "r", + "e", + "s", + "s", + "i", + "o", + "n", + "s", + "\n", "a", "n", "d", " ", - "h", - "e", "a", - "t", " ", - "f", - "o", - "l", + "s", + "i", + "n", + "g", "l", - "o", - "w", - "\n", - "p", - "o", - "w", "e", + " ", + "b", "r", - "_", - "d", - "i", - "s", - "p", + "e", "a", + "k", + "p", + "o", + "i", + "n", "t", - "c", - "h", " ", - "=", - " ", - "x", - "r", - ".", "D", "a", "t", @@ -7086,177 +2683,269 @@ "r", "a", "y", - "(", - "[", - "2", - "0", - ",", - " ", - "6", - "0", - ",", " ", - "9", - "0", - "]", - ",", + "w", + "h", + "o", + "s", + "e", " ", "c", "o", "o", "r", "d", - "s", - "=", - "[", - "t", "i", - "m", - "e", - "]", - ")", - "\n", - "m", - "7", - ".", - "a", - "d", - "d", - "_", - "c", - "o", "n", - "s", - "t", - "r", "a", - "i", - "n", "t", - "s", - "(", - "p", - "o", - "w", - "e", - "r", - " ", - "=", - "=", - " ", - "p", - "o", - "w", "e", - "r", - "_", - "d", - "i", "s", - "p", + " ", + "m", "a", "t", "c", "h", - ",", " ", - "n", - "a", - "m", - "e", - "=", - "\"", - "p", - "o", - "w", - "e", - "r", - "_", - "d", - "i", - "s", - "p", - "a", "t", - "c", "h", - "\"", - ")", - "\n", - "\n", - "m", - "7", - ".", - "a", - "d", - "d", - "_", - "o", - "b", - "j", "e", + " ", + "d", + "i", "c", "t", "i", - "v", - "e", - "(", - "f", - "u", + "o", + "n", + "a", + "r", + "y", + " ", + "k", "e", - "l", - ".", + "y", "s", - "u", - "m", - "(", - ")", - ")" - ], + "." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:27.508052Z", + "start_time": "2026-04-01T11:02:27.504570Z" + } + }, "outputs": [], - "execution_count": null + "source": [ + "# CHP operating points: as load increases, power, fuel, and heat all change\n", + "bp_chp = linopy.breakpoints(\n", + " {\n", + " \"power\": [0, 30, 60, 100],\n", + " \"fuel\": [0, 40, 85, 160],\n", + " \"heat\": [0, 25, 55, 95],\n", + " },\n", + " dim=\"var\",\n", + ")\n", + "print(\"CHP breakpoints:\")\n", + "print(bp_chp.to_pandas())" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T10:19:45.169858Z", - "start_time": "2026-04-01T10:19:45.096263Z" + "end_time": "2026-04-01T11:02:27.622928Z", + "start_time": "2026-04-01T11:02:27.514473Z" } }, + "outputs": [], "source": [ - "m7.solve(reformulate_sos=\"auto\")" - ], + "m7 = linopy.Model()\n", + "\n", + "power = m7.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", + "fuel = m7.add_variables(name=\"fuel\", lower=0, coords=[time])\n", + "heat = m7.add_variables(name=\"heat\", lower=0, coords=[time])\n", + "\n", + "# N-variable: all three linked through shared interpolation weights\n", + "m7.add_piecewise_constraints(\n", + " (power, bp_chp.sel(var=\"power\")),\n", + " (fuel, bp_chp.sel(var=\"fuel\")),\n", + " (heat, bp_chp.sel(var=\"heat\")),\n", + " name=\"chp\",\n", + " method=\"sos2\",\n", + ")\n", + "\n", + "# Fixed power dispatch determines the operating point — fuel and heat follow\n", + "power_dispatch = xr.DataArray([20, 60, 90], coords=[time])\n", + "m7.add_constraints(power == power_dispatch, name=\"power_dispatch\")\n", + "\n", + "m7.add_objective(fuel.sum())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:27.666695Z", + "start_time": "2026-04-01T11:02:27.629711Z" + } + }, "outputs": [], - "execution_count": null + "source": [ + "m7.solve(reformulate_sos=\"auto\")" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T10:19:45.191988Z", - "start_time": "2026-04-01T10:19:45.182836Z" + "end_time": "2026-04-01T11:02:27.683278Z", + "start_time": "2026-04-01T11:02:27.677473Z" } }, + "outputs": [], "source": [ "m7.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas().round(2)" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T10:19:45.453810Z", - "start_time": "2026-04-01T10:19:45.212630Z" + "end_time": "2026-04-01T11:02:27.775707Z", + "start_time": "2026-04-01T11:02:27.690815Z" } }, + "outputs": [], "source": [ "plot_pwl_results(m7, bp_chp, power_dispatch)" - ], + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 8. Per-entity breakpoints — Fleet of generators\n\nWhen different generators have different efficiency curves, pass\nper-entity breakpoints using a dict with `breakpoints()`. The breakpoint\narrays are auto-broadcast over the remaining dimensions (here `time`)." + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:27.783889Z", + "start_time": "2026-04-01T11:02:27.779605Z" + } + }, + "outputs": [], + "source": [ + "gens = pd.Index([\"gas\", \"coal\"], name=\"gen\")\n", + "\n", + "# Each generator has its own heat-rate curve\n", + "x_gen = linopy.breakpoints(\n", + " {\"gas\": [0, 30, 60, 100], \"coal\": [0, 50, 100, 150]}, dim=\"gen\"\n", + ")\n", + "y_gen = linopy.breakpoints(\n", + " {\"gas\": [0, 40, 90, 180], \"coal\": [0, 55, 130, 225]}, dim=\"gen\"\n", + ")\n", + "print(\"Power breakpoints:\\n\", x_gen.to_pandas())\n", + "print(\"Fuel breakpoints:\\n\", y_gen.to_pandas())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:27.864290Z", + "start_time": "2026-04-01T11:02:27.789775Z" + } + }, + "outputs": [], + "source": [ + "m8 = linopy.Model()\n", + "\n", + "power = m8.add_variables(name=\"power\", lower=0, upper=150, coords=[gens, time])\n", + "fuel = m8.add_variables(name=\"fuel\", lower=0, coords=[gens, time])\n", + "\n", + "# Per-entity breakpoints: each generator gets its own curve\n", + "m8.add_piecewise_constraints(\n", + " (power, x_gen),\n", + " (fuel, y_gen),\n", + " name=\"pwl\",\n", + ")\n", + "\n", + "demand8 = xr.DataArray([80, 120, 60], coords=[time])\n", + "m8.add_constraints(power.sum(\"gen\") >= demand8, name=\"demand\")\n", + "m8.add_objective(fuel.sum())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:27.936255Z", + "start_time": "2026-04-01T11:02:27.868836Z" + } + }, + "outputs": [], + "source": [ + "m8.solve(reformulate_sos=\"auto\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:27.956505Z", + "start_time": "2026-04-01T11:02:27.949691Z" + } + }, + "outputs": [], + "source": [ + "m8.solution[[\"power\", \"fuel\"]].to_dataframe().round(2)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T11:02:28.070069Z", + "start_time": "2026-04-01T11:02:27.975502Z" + } + }, "outputs": [], - "execution_count": null + "source": [ + "sol = m8.solution\n", + "fig, axes = plt.subplots(1, 2, figsize=(10, 3.5))\n", + "\n", + "for i, gen in enumerate(gens):\n", + " ax = axes[i]\n", + " xp = x_gen.sel(gen=gen).values\n", + " yp = y_gen.sel(gen=gen).values\n", + " ax.plot(xp, yp, \"o-\", color=f\"C{i}\", label=\"Breakpoints\")\n", + " for t in time:\n", + " ax.plot(\n", + " float(sol[\"power\"].sel(gen=gen, time=t)),\n", + " float(sol[\"fuel\"].sel(gen=gen, time=t)),\n", + " \"D\",\n", + " color=\"black\",\n", + " ms=8,\n", + " )\n", + " ax.set(xlabel=\"Power [MW]\", ylabel=\"Fuel\", title=f\"{gen} heat-rate curve\")\n", + " ax.legend()\n", + "\n", + "plt.tight_layout()" + ] } ], "metadata": { diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 2035521f..198d5cef 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -870,11 +870,15 @@ def add_piecewise_constraints( f"got {type(pair)}." ) - # Coerce all breakpoints + # Coerce all breakpoints. Drop scalar coordinates (e.g. left over + # from bp.sel(var="power")) so they don't conflict when stacking. coerced: list[tuple[LinExprLike, DataArray]] = [] for expr, bp in pairs: if not isinstance(bp, DataArray): bp = _coerce_breaks(bp) + scalar_coords = [c for c in bp.coords if c not in bp.dims] + if scalar_coords: + bp = bp.drop_vars(scalar_coords) coerced.append((expr, bp)) # Check for disjunctive (segment dimension) on first pair From b449c7ef214242b4b5364c6b496000d3635101d0 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:04:54 +0200 Subject: [PATCH 17/30] docs: use fuel as x-axis in CHP plot for physical clarity Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/piecewise-linear-constraints.ipynb | 446 ++++++++++---------- 1 file changed, 229 insertions(+), 217 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 904ce8d7..cca2d6e3 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -7,21 +7,19 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:26.188969Z", - "start_time": "2026-04-01T11:02:26.183809Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.167007Z", "iopub.status.busy": "2026-03-06T11:51:29.166576Z", "iopub.status.idle": "2026-03-06T11:51:29.185103Z", "shell.execute_reply": "2026-03-06T11:51:29.184712Z", "shell.execute_reply.started": "2026-03-06T11:51:29.166974Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:33.585886Z", + "start_time": "2026-04-01T11:04:33.573556Z" } }, - "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "import pandas as pd\n", @@ -105,7 +103,9 @@ " )\n", " ax2.legend()\n", " plt.tight_layout()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -120,45 +120,43 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:26.200443Z", - "start_time": "2026-04-01T11:02:26.196608Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.185693Z", "iopub.status.busy": "2026-03-06T11:51:29.185601Z", "iopub.status.idle": "2026-03-06T11:51:29.199760Z", "shell.execute_reply": "2026-03-06T11:51:29.199416Z", "shell.execute_reply.started": "2026-03-06T11:51:29.185683Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:33.606760Z", + "start_time": "2026-04-01T11:04:33.598816Z" } }, - "outputs": [], "source": [ "x_pts1 = linopy.breakpoints([0, 30, 60, 100])\n", "y_pts1 = linopy.breakpoints([0, 36, 84, 170])\n", "print(\"x_pts:\", x_pts1.values)\n", "print(\"y_pts:\", y_pts1.values)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:26.251435Z", - "start_time": "2026-04-01T11:02:26.207916Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.200170Z", "iopub.status.busy": "2026-03-06T11:51:29.200087Z", "iopub.status.idle": "2026-03-06T11:51:29.266847Z", "shell.execute_reply": "2026-03-06T11:51:29.266379Z", "shell.execute_reply.started": "2026-03-06T11:51:29.200161Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:33.690508Z", + "start_time": "2026-04-01T11:04:33.614365Z" } }, - "outputs": [], "source": [ "m1 = linopy.Model()\n", "\n", @@ -176,71 +174,73 @@ "demand1 = xr.DataArray([50, 80, 30], coords=[time])\n", "m1.add_constraints(power >= demand1, name=\"demand\")\n", "m1.add_objective(fuel.sum())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:26.308193Z", - "start_time": "2026-04-01T11:02:26.255362Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.267522Z", "iopub.status.busy": "2026-03-06T11:51:29.267433Z", "iopub.status.idle": "2026-03-06T11:51:29.326758Z", "shell.execute_reply": "2026-03-06T11:51:29.326518Z", "shell.execute_reply.started": "2026-03-06T11:51:29.267514Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:33.823728Z", + "start_time": "2026-04-01T11:04:33.694693Z" } }, - "outputs": [], "source": [ "m1.solve(reformulate_sos=\"auto\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:26.330720Z", - "start_time": "2026-04-01T11:02:26.323039Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.327139Z", "iopub.status.busy": "2026-03-06T11:51:29.327044Z", "iopub.status.idle": "2026-03-06T11:51:29.339334Z", "shell.execute_reply": "2026-03-06T11:51:29.338974Z", "shell.execute_reply.started": "2026-03-06T11:51:29.327130Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:33.856430Z", + "start_time": "2026-04-01T11:04:33.841039Z" } }, - "outputs": [], "source": [ "m1.solution[[\"power\", \"fuel\"]].to_pandas()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:26.512495Z", - "start_time": "2026-04-01T11:02:26.341217Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.339689Z", "iopub.status.busy": "2026-03-06T11:51:29.339608Z", "iopub.status.idle": "2026-03-06T11:51:29.489677Z", "shell.execute_reply": "2026-03-06T11:51:29.489280Z", "shell.execute_reply.started": "2026-03-06T11:51:29.339680Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:34.106239Z", + "start_time": "2026-04-01T11:04:33.876509Z" } }, - "outputs": [], "source": [ "bp1 = linopy.breakpoints({\"power\": x_pts1.values, \"fuel\": y_pts1.values}, dim=\"var\")\n", "plot_pwl_results(m1, bp1, demand1, color=\"C0\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -255,45 +255,43 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:26.582521Z", - "start_time": "2026-04-01T11:02:26.577758Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.490092Z", "iopub.status.busy": "2026-03-06T11:51:29.490011Z", "iopub.status.idle": "2026-03-06T11:51:29.500894Z", "shell.execute_reply": "2026-03-06T11:51:29.500558Z", "shell.execute_reply.started": "2026-03-06T11:51:29.490084Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:34.127978Z", + "start_time": "2026-04-01T11:04:34.119621Z" } }, - "outputs": [], "source": [ "x_pts2 = linopy.breakpoints([0, 50, 100, 150])\n", "y_pts2 = linopy.breakpoints([0, 55, 130, 225])\n", "print(\"x_pts:\", x_pts2.values)\n", "print(\"y_pts:\", y_pts2.values)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:26.673055Z", - "start_time": "2026-04-01T11:02:26.598926Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.501317Z", "iopub.status.busy": "2026-03-06T11:51:29.501216Z", "iopub.status.idle": "2026-03-06T11:51:29.604024Z", "shell.execute_reply": "2026-03-06T11:51:29.603543Z", "shell.execute_reply.started": "2026-03-06T11:51:29.501307Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:34.296782Z", + "start_time": "2026-04-01T11:04:34.145513Z" } }, - "outputs": [], "source": [ "m2 = linopy.Model()\n", "\n", @@ -310,71 +308,73 @@ "demand2 = xr.DataArray([80, 120, 50], coords=[time])\n", "m2.add_constraints(power >= demand2, name=\"demand\")\n", "m2.add_objective(fuel.sum())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:26.734253Z", - "start_time": "2026-04-01T11:02:26.677880Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.604434Z", "iopub.status.busy": "2026-03-06T11:51:29.604359Z", "iopub.status.idle": "2026-03-06T11:51:29.680947Z", "shell.execute_reply": "2026-03-06T11:51:29.680667Z", "shell.execute_reply.started": "2026-03-06T11:51:29.604427Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:34.409396Z", + "start_time": "2026-04-01T11:04:34.301301Z" } }, - "outputs": [], "source": [ "m2.solve(reformulate_sos=\"auto\");" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:26.752710Z", - "start_time": "2026-04-01T11:02:26.743897Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.681833Z", "iopub.status.busy": "2026-03-06T11:51:29.681725Z", "iopub.status.idle": "2026-03-06T11:51:29.698558Z", "shell.execute_reply": "2026-03-06T11:51:29.698011Z", "shell.execute_reply.started": "2026-03-06T11:51:29.681822Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:34.428933Z", + "start_time": "2026-04-01T11:04:34.414748Z" } }, - "outputs": [], "source": [ "m2.solution[[\"power\", \"fuel\"]].to_pandas()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:26.868254Z", - "start_time": "2026-04-01T11:02:26.763276Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.699350Z", "iopub.status.busy": "2026-03-06T11:51:29.699116Z", "iopub.status.idle": "2026-03-06T11:51:29.852000Z", "shell.execute_reply": "2026-03-06T11:51:29.851741Z", "shell.execute_reply.started": "2026-03-06T11:51:29.699334Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:34.647218Z", + "start_time": "2026-04-01T11:04:34.448797Z" } }, - "outputs": [], "source": [ "bp2 = linopy.breakpoints({\"power\": x_pts2.values, \"fuel\": y_pts2.values}, dim=\"var\")\n", "plot_pwl_results(m2, bp2, demand2, color=\"C1\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -394,21 +394,19 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:26.878327Z", - "start_time": "2026-04-01T11:02:26.872753Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.852397Z", "iopub.status.busy": "2026-03-06T11:51:29.852305Z", "iopub.status.idle": "2026-03-06T11:51:29.866500Z", "shell.execute_reply": "2026-03-06T11:51:29.866141Z", "shell.execute_reply.started": "2026-03-06T11:51:29.852387Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:34.655170Z", + "start_time": "2026-04-01T11:04:34.651291Z" } }, - "outputs": [], "source": [ "# x-breakpoints define where each segment lives on the power axis\n", "# y-breakpoints define the corresponding cost values\n", @@ -416,25 +414,25 @@ "y_seg = linopy.segments([(0, 0), (125, 200)])\n", "print(\"x segments:\\n\", x_seg.to_pandas())\n", "print(\"y segments:\\n\", y_seg.to_pandas())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:26.947790Z", - "start_time": "2026-04-01T11:02:26.885620Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.866940Z", "iopub.status.busy": "2026-03-06T11:51:29.866839Z", "iopub.status.idle": "2026-03-06T11:51:29.955272Z", "shell.execute_reply": "2026-03-06T11:51:29.954810Z", "shell.execute_reply.started": "2026-03-06T11:51:29.866931Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:34.721948Z", + "start_time": "2026-04-01T11:04:34.662824Z" } }, - "outputs": [], "source": [ "m3 = linopy.Model()\n", "\n", @@ -451,49 +449,51 @@ "demand3 = xr.DataArray([10, 70, 90], coords=[time])\n", "m3.add_constraints(power + backup >= demand3, name=\"demand\")\n", "m3.add_objective((cost + 10 * backup).sum())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.002220Z", - "start_time": "2026-04-01T11:02:26.953483Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:29.955750Z", "iopub.status.busy": "2026-03-06T11:51:29.955667Z", "iopub.status.idle": "2026-03-06T11:51:30.027311Z", "shell.execute_reply": "2026-03-06T11:51:30.026945Z", "shell.execute_reply.started": "2026-03-06T11:51:29.955741Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:34.781046Z", + "start_time": "2026-04-01T11:04:34.724468Z" } }, - "outputs": [], "source": [ "m3.solve(reformulate_sos=\"auto\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.020529Z", - "start_time": "2026-04-01T11:02:27.014185Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.028114Z", "iopub.status.busy": "2026-03-06T11:51:30.027864Z", "iopub.status.idle": "2026-03-06T11:51:30.043138Z", "shell.execute_reply": "2026-03-06T11:51:30.042813Z", "shell.execute_reply.started": "2026-03-06T11:51:30.028095Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:34.788056Z", + "start_time": "2026-04-01T11:04:34.783503Z" } }, - "outputs": [], "source": [ "m3.solution[[\"power\", \"cost\", \"backup\"]].to_pandas()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -883,21 +883,19 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.051903Z", - "start_time": "2026-04-01T11:02:27.026742Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.043492Z", "iopub.status.busy": "2026-03-06T11:51:30.043410Z", "iopub.status.idle": "2026-03-06T11:51:30.113382Z", "shell.execute_reply": "2026-03-06T11:51:30.112320Z", "shell.execute_reply.started": "2026-03-06T11:51:30.043484Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:34.821083Z", + "start_time": "2026-04-01T11:04:34.795038Z" } }, - "outputs": [], "source": [ "x_pts4 = linopy.breakpoints([0, 40, 80, 120])\n", "# Concave curve: decreasing marginal fuel per MW\n", @@ -916,71 +914,73 @@ "m4.add_constraints(power == demand4, name=\"demand\")\n", "# Maximize fuel (to push against the upper bound)\n", "m4.add_objective(-fuel.sum())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.087177Z", - "start_time": "2026-04-01T11:02:27.056184Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.113818Z", "iopub.status.busy": "2026-03-06T11:51:30.113727Z", "iopub.status.idle": "2026-03-06T11:51:30.171329Z", "shell.execute_reply": "2026-03-06T11:51:30.170942Z", "shell.execute_reply.started": "2026-03-06T11:51:30.113810Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:34.853024Z", + "start_time": "2026-04-01T11:04:34.823664Z" } }, - "outputs": [], "source": [ "m4.solve(reformulate_sos=\"auto\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.096041Z", - "start_time": "2026-04-01T11:02:27.091468Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.172009Z", "iopub.status.busy": "2026-03-06T11:51:30.171791Z", "iopub.status.idle": "2026-03-06T11:51:30.191956Z", "shell.execute_reply": "2026-03-06T11:51:30.191556Z", "shell.execute_reply.started": "2026-03-06T11:51:30.171993Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:34.865733Z", + "start_time": "2026-04-01T11:04:34.861523Z" } }, - "outputs": [], "source": [ "m4.solution[[\"power\", \"fuel\"]].to_pandas()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.193996Z", - "start_time": "2026-04-01T11:02:27.113630Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.192604Z", "iopub.status.busy": "2026-03-06T11:51:30.192376Z", "iopub.status.idle": "2026-03-06T11:51:30.345074Z", "shell.execute_reply": "2026-03-06T11:51:30.344642Z", "shell.execute_reply.started": "2026-03-06T11:51:30.192590Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:34.953458Z", + "start_time": "2026-04-01T11:04:34.873306Z" } }, - "outputs": [], "source": [ "bp4 = linopy.breakpoints({\"power\": x_pts4.values, \"fuel\": y_pts4.values}, dim=\"var\")\n", "plot_pwl_results(m4, bp4, demand4, color=\"C4\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -995,27 +995,27 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.207526Z", - "start_time": "2026-04-01T11:02:27.204734Z" - }, "execution": { "iopub.execute_input": "2026-03-06T11:51:30.345523Z", "iopub.status.busy": "2026-03-06T11:51:30.345404Z", "iopub.status.idle": "2026-03-06T11:51:30.357312Z", "shell.execute_reply": "2026-03-06T11:51:30.356954Z", "shell.execute_reply.started": "2026-03-06T11:51:30.345513Z" + }, + "ExecuteTime": { + "end_time": "2026-04-01T11:04:34.959715Z", + "start_time": "2026-04-01T11:04:34.956645Z" } }, - "outputs": [], "source": [ "# Marginal costs: $1.1/MW for 0-50, $1.5/MW for 50-100, $1.9/MW for 100-150\n", "x_pts5 = linopy.breakpoints([0, 50, 100, 150])\n", "y_pts5 = linopy.breakpoints(slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0)\n", "print(\"y breakpoints from slopes:\", y_pts5.values)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -1932,14 +1932,12 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.220030Z", - "start_time": "2026-04-01T11:02:27.217629Z" + "end_time": "2026-04-01T11:04:34.967501Z", + "start_time": "2026-04-01T11:04:34.964517Z" } }, - "outputs": [], "source": [ "# Unit parameters: operates between 30-100 MW when on\n", "p_min, p_max = 30, 100\n", @@ -1950,18 +1948,18 @@ "y_pts6 = linopy.breakpoints([fuel_min, 90, fuel_max])\n", "print(\"Power breakpoints:\", x_pts6.values)\n", "print(\"Fuel breakpoints: \", y_pts6.values)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.315829Z", - "start_time": "2026-04-01T11:02:27.233033Z" + "end_time": "2026-04-01T11:04:35.057769Z", + "start_time": "2026-04-01T11:04:34.973035Z" } }, - "outputs": [], "source": [ "m6 = linopy.Model()\n", "\n", @@ -1987,50 +1985,52 @@ "\n", "# Objective: fuel + startup cost + backup at $5/MW\n", "m6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.384451Z", - "start_time": "2026-04-01T11:02:27.320144Z" + "end_time": "2026-04-01T11:04:35.162579Z", + "start_time": "2026-04-01T11:04:35.060891Z" } }, - "outputs": [], "source": [ "m6.solve(reformulate_sos=\"auto\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.402559Z", - "start_time": "2026-04-01T11:02:27.394836Z" + "end_time": "2026-04-01T11:04:35.181403Z", + "start_time": "2026-04-01T11:04:35.176031Z" } }, - "outputs": [], "source": [ "m6.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.498992Z", - "start_time": "2026-04-01T11:02:27.412130Z" + "end_time": "2026-04-01T11:04:35.285661Z", + "start_time": "2026-04-01T11:04:35.189219Z" } }, - "outputs": [], "source": [ "bp6 = linopy.breakpoints({\"power\": x_pts6.values, \"fuel\": y_pts6.values}, dim=\"var\")\n", "plot_pwl_results(m6, bp6, demand6, color=\"C2\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -2732,14 +2732,12 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.508052Z", - "start_time": "2026-04-01T11:02:27.504570Z" + "end_time": "2026-04-01T11:04:35.298514Z", + "start_time": "2026-04-01T11:04:35.294926Z" } }, - "outputs": [], "source": [ "# CHP operating points: as load increases, power, fuel, and heat all change\n", "bp_chp = linopy.breakpoints(\n", @@ -2752,18 +2750,18 @@ ")\n", "print(\"CHP breakpoints:\")\n", "print(bp_chp.to_pandas())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.622928Z", - "start_time": "2026-04-01T11:02:27.514473Z" + "end_time": "2026-04-01T11:04:35.366295Z", + "start_time": "2026-04-01T11:04:35.311584Z" } }, - "outputs": [], "source": [ "m7 = linopy.Model()\n", "\n", @@ -2785,49 +2783,63 @@ "m7.add_constraints(power == power_dispatch, name=\"power_dispatch\")\n", "\n", "m7.add_objective(fuel.sum())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.666695Z", - "start_time": "2026-04-01T11:02:27.629711Z" + "end_time": "2026-04-01T11:04:35.414186Z", + "start_time": "2026-04-01T11:04:35.376453Z" } }, - "outputs": [], "source": [ "m7.solve(reformulate_sos=\"auto\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.683278Z", - "start_time": "2026-04-01T11:02:27.677473Z" + "end_time": "2026-04-01T11:04:35.425823Z", + "start_time": "2026-04-01T11:04:35.420515Z" } }, - "outputs": [], "source": [ "m7.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas().round(2)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.775707Z", - "start_time": "2026-04-01T11:02:27.690815Z" + "end_time": "2026-04-01T11:04:35.522236Z", + "start_time": "2026-04-01T11:04:35.434392Z" } }, - "outputs": [], - "source": [ - "plot_pwl_results(m7, bp_chp, power_dispatch)" - ] + "source": "plot_pwl_results(m7, bp_chp, power_dispatch, x_name=\"fuel\")", + "outputs": [ + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "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xIkTVQjv2bOnWifN1OfOnYvTp0+jTp06+OGHH1TglrJf88svv8DX1xeXL19GqVKl+FEQvSSGbiIr0KhRI1WwaiRwSwGcGhloTe5+N2vWLNH66OhoVSgTERGR+apUqZL+33Z2dsibNy8qVqyoXyfNx0VQUJD6eurUKezcuTPZ/uFXr15l6CbKAIZuIisgIVv6bhmytbWFTqdLtC4mJkb/76dPn6qvGzduhLe393N9xImIiMh8OTg4JFqWvuCG62RZG79FK/dbt26NSZMmPbevggULmvx4ibIzhm4iKyWjl8uAKYZOnjypL5DLlSunwvWtW7fYlJyIiCibq1atGv744w8ULVoU9vaMCETGxIHUiKyUDJYiA6XIQGlXrlzBmDFjEoVwGdn0f//7nxo8TQZjkaZlMvDKrFmz1DIRERFlH3379sXjx4/RpUsXHDlyRJX7W7ZsUaOdx8XFZfXhEVk0hm4iK9W8eXOMGjUKQ4cORc2aNREWFoYPPvgg0Tbjxo1T28go5mXLllXzfUtzc5lCjIiIiLKPQoUKYd++fSpgy8jm0v9bphzLlSuX6pJGRC/PRpe0UycRERERERERGQVvWxERERERERGZCEM3ERERERERkYkwdBMRERERERGZCEM3ERERERERkYkwdBMRERERERGZCEM3ERERERERkYkwdBMRERERERGZCEM3ERERERERkYkwdBMRERERERGZCEM3ERERERERkYkwdBMRERERERGZCEM3EREREREREUzj/wFkMYA2K4304wAAAABJRU5ErkJggg==" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + } + ], + "execution_count": null }, { "cell_type": "markdown", @@ -2836,14 +2848,12 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.783889Z", - "start_time": "2026-04-01T11:02:27.779605Z" + "end_time": "2026-04-01T11:04:35.530322Z", + "start_time": "2026-04-01T11:04:35.525288Z" } }, - "outputs": [], "source": [ "gens = pd.Index([\"gas\", \"coal\"], name=\"gen\")\n", "\n", @@ -2856,18 +2866,18 @@ ")\n", "print(\"Power breakpoints:\\n\", x_gen.to_pandas())\n", "print(\"Fuel breakpoints:\\n\", y_gen.to_pandas())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.864290Z", - "start_time": "2026-04-01T11:02:27.789775Z" + "end_time": "2026-04-01T11:04:35.618316Z", + "start_time": "2026-04-01T11:04:35.539292Z" } }, - "outputs": [], "source": [ "m8 = linopy.Model()\n", "\n", @@ -2884,46 +2894,46 @@ "demand8 = xr.DataArray([80, 120, 60], coords=[time])\n", "m8.add_constraints(power.sum(\"gen\") >= demand8, name=\"demand\")\n", "m8.add_objective(fuel.sum())" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.936255Z", - "start_time": "2026-04-01T11:02:27.868836Z" + "end_time": "2026-04-01T11:04:35.680516Z", + "start_time": "2026-04-01T11:04:35.620789Z" } }, - "outputs": [], "source": [ "m8.solve(reformulate_sos=\"auto\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:02:27.956505Z", - "start_time": "2026-04-01T11:02:27.949691Z" + "end_time": "2026-04-01T11:04:35.689476Z", + "start_time": "2026-04-01T11:04:35.683696Z" } }, - "outputs": [], "source": [ "m8.solution[[\"power\", \"fuel\"]].to_dataframe().round(2)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:02:28.070069Z", - "start_time": "2026-04-01T11:02:27.975502Z" + "end_time": "2026-04-01T11:04:35.788222Z", + "start_time": "2026-04-01T11:04:35.698204Z" } }, - "outputs": [], "source": [ "sol = m8.solution\n", "fig, axes = plt.subplots(1, 2, figsize=(10, 3.5))\n", @@ -2945,7 +2955,9 @@ " ax.legend()\n", "\n", "plt.tight_layout()" - ] + ], + "outputs": [], + "execution_count": null } ], "metadata": { From b43d9fef9ab1879b21cda0fac64f29d6880c0be7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:29:26 +0200 Subject: [PATCH 18/30] docs: fix per-entity plot to use fuel on x-axis with correct data Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/piecewise-linear-constraints.ipynb | 1323 ++++++++++++++++--- 1 file changed, 1170 insertions(+), 153 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index cca2d6e3..f7fd39c6 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -16,8 +16,8 @@ "shell.execute_reply.started": "2026-03-06T11:51:29.166974Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:33.585886Z", - "start_time": "2026-04-01T11:04:33.573556Z" + "end_time": "2026-04-01T11:08:36.934172Z", + "start_time": "2026-04-01T11:08:36.927037Z" } }, "source": [ @@ -105,7 +105,7 @@ " plt.tight_layout()" ], "outputs": [], - "execution_count": null + "execution_count": 316 }, { "cell_type": "markdown", @@ -129,8 +129,8 @@ "shell.execute_reply.started": "2026-03-06T11:51:29.185683Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:33.606760Z", - "start_time": "2026-04-01T11:04:33.598816Z" + "end_time": "2026-04-01T11:08:36.947252Z", + "start_time": "2026-04-01T11:08:36.944290Z" } }, "source": [ @@ -139,8 +139,17 @@ "print(\"x_pts:\", x_pts1.values)\n", "print(\"y_pts:\", y_pts1.values)" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "x_pts: [ 0. 30. 60. 100.]\n", + "y_pts: [ 0. 36. 84. 170.]\n" + ] + } + ], + "execution_count": 317 }, { "cell_type": "code", @@ -153,8 +162,8 @@ "shell.execute_reply.started": "2026-03-06T11:51:29.200161Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:33.690508Z", - "start_time": "2026-04-01T11:04:33.614365Z" + "end_time": "2026-04-01T11:08:36.999555Z", + "start_time": "2026-04-01T11:08:36.951114Z" } }, "source": [ @@ -176,7 +185,7 @@ "m1.add_objective(fuel.sum())" ], "outputs": [], - "execution_count": null + "execution_count": 318 }, { "cell_type": "code", @@ -189,15 +198,80 @@ "shell.execute_reply.started": "2026-03-06T11:51:29.267514Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:33.823728Z", - "start_time": "2026-04-01T11:04:33.694693Z" + "end_time": "2026-04-01T11:08:37.057492Z", + "start_time": "2026-04-01T11:08:37.002487Z" } }, "source": [ "m1.solve(reformulate_sos=\"auto\")" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Set parameter Username\n", + "Academic license - for non-commercial use only - expires 2026-12-18\n", + "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-6f6dxleu.lp\n", + "Reading time = 0.00 seconds\n", + "obj: 12 rows, 18 columns, 39 nonzeros\n", + "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n", + "\n", + "CPU model: Apple M3\n", + "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n", + "\n", + "Optimize a model with 12 rows, 18 columns and 39 nonzeros (Min)\n", + "Model fingerprint: 0x109ede56\n", + "Model has 3 linear objective coefficients\n", + "Model has 3 SOS constraints\n", + "Variable types: 18 continuous, 0 integer (0 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 2e+02]\n", + " Objective range [1e+00, 1e+00]\n", + " Bounds range [1e+00, 1e+02]\n", + " RHS range [1e+00, 8e+01]\n", + "\n", + "Presolve removed 8 rows and 13 columns\n", + "Presolve time: 0.00s\n", + "Presolved: 4 rows, 5 columns, 10 nonzeros\n", + "Variable types: 4 continuous, 1 integer (1 binary)\n", + "Found heuristic solution: objective 231.0000000\n", + "\n", + "Root relaxation: cutoff, 2 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 cutoff 0 231.00000 231.00000 0.00% - 0s\n", + "\n", + "Explored 1 nodes (2 simplex iterations) in 0.01 seconds (0.00 work units)\n", + "Thread count was 8 (of 8 available processors)\n", + "\n", + "Solution count 1: 231 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 2.310000000000e+02, best bound 2.310000000000e+02, gap 0.0000%\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Dual values of MILP couldn't be parsed\n" + ] + }, + { + "data": { + "text/plain": [ + "('ok', 'optimal')" + ] + }, + "execution_count": 319, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 319 }, { "cell_type": "code", @@ -210,15 +284,78 @@ "shell.execute_reply.started": "2026-03-06T11:51:29.327130Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:33.856430Z", - "start_time": "2026-04-01T11:04:33.841039Z" + "end_time": "2026-04-01T11:08:37.072609Z", + "start_time": "2026-04-01T11:08:37.068099Z" } }, "source": [ "m1.solution[[\"power\", \"fuel\"]].to_pandas()" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "data": { + "text/plain": [ + " power fuel\n", + "time \n", + "1 50.0 68.0\n", + "2 80.0 127.0\n", + "3 30.0 36.0" + ], + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
powerfuel
time
150.068.0
280.0127.0
330.036.0
\n", + "
" + ] + }, + "execution_count": 320, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 320 }, { "cell_type": "code", @@ -231,16 +368,30 @@ "shell.execute_reply.started": "2026-03-06T11:51:29.339680Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.106239Z", - "start_time": "2026-04-01T11:04:33.876509Z" + "end_time": "2026-04-01T11:08:37.172658Z", + "start_time": "2026-04-01T11:08:37.081859Z" } }, "source": [ "bp1 = linopy.breakpoints({\"power\": x_pts1.values, \"fuel\": y_pts1.values}, dim=\"var\")\n", "plot_pwl_results(m1, bp1, demand1, color=\"C0\")" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAFUCAYAAAA57l+/AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/TGe4hAAAACXBIWXMAAA9hAAAPYQGoP6dpAABmTElEQVR4nO3dB3hU1dbG8TcBEjpIB6kqTaVIR5FeVQQBC+KVJlgABe61oKKABdR7BUQEC81PsYCAFZSOBaQoKoI0adItgICEkvmetccZJyGBBDKZSeb/e55Dcs5MJjsnQ/ZZZ6+9dpTH4/EIAAAAAACkuei0f0kAAAAAAEDQDQAAAABAEDHSDQAAAABAkBB0AwAAAAAQJATdAAAAAAAECUE3AAAAAABBQtANAAAAAECQEHQDAAAAABAkBN0AAAAAAAQJQTcAAAAQIkOGDFFUVFSGP//2M/Tt2zfUzQDCEkE3kM4mT57sOibflj17dlWoUMF1VHv37nXPWb58uXts5MiRp319u3bt3GOTJk067bGGDRvqwgsv9O83btxYl19+eZB/IgAAcKZ+vkSJEmrVqpVeeOEF/fnnn2F5sj755BN3AwBA2iPoBkJk2LBh+r//+z+9+OKLuvLKKzVu3DjVr19fR48eVY0aNZQzZ0598cUXp33dV199paxZs+rLL79McPz48eNasWKFrrrqqnT8KQAAwJn6eevf+/Xr5471799fVapU0ffff+9/3qOPPqq//vorLILuoUOHhroZQKaUNdQNACJVmzZtVKtWLff5HXfcoYIFC+r555/X+++/r86dO6tu3bqnBdbr16/Xr7/+qltvvfW0gHzVqlU6duyYGjRooIzAbi7YjQUAADJ7P28GDRqkBQsW6LrrrtP111+vdevWKUeOHO5Gum0AMi9GuoEw0bRpU/dxy5Yt7qMFz5ZuvmnTJv9zLAjPmzevevfu7Q/AAx/zfV1aOHDggAYMGKCyZcsqNjZWJUuW1O233+7/nr70ua1btyb4ukWLFrnj9jFxmrvdGLAUeAu2H374YXfhcdFFFyX5/W3UP/BixbzxxhuqWbOmu0gpUKCAbrnlFu3YsSNNfl4AANKjrx88eLC2bdvm+rTk5nTPnTvX9ef58+dX7ty5VbFiRddvJu5r33nnHXe8WLFiypUrlwvmE/eLn3/+uW688UaVLl3a9eelSpVy/Xvg6Hq3bt00duxY93lgarxPfHy8Ro8e7UbpLV2+cOHCat26tVauXHnazzhr1izX59v3uuyyyzRnzpw0PINAxsRtNSBMbN682X20Ee/A4NlGtC+55BJ/YF2vXj03Cp4tWzaXam4drO+xPHnyqFq1aufdlsOHD+vqq692d+F79Ojh0t0t2P7ggw/0yy+/qFChQql+zd9++83d9bdA+bbbblPRokVdAG2BvKXF165d2/9cuxhZtmyZnnvuOf+xp556yl2o3HTTTS4zYP/+/RozZowL4r/99lt3YQIAQLj717/+5QLlzz77TL169Trt8R9//NHdlK5atapLUbfg1W7AJ85+8/WNFhw/+OCD2rdvn0aNGqXmzZtr9erV7ga1mTZtmssuu/vuu901htWNsf7T+nN7zNx5553atWuXC/YtJT6xnj17upvt1o9bH3zy5EkXzFtfHXiD3K5ZZsyYoXvuucddk9gc9o4dO2r79u3+6xsgInkApKtJkyZ57L/evHnzPPv37/fs2LHD8/bbb3sKFizoyZEjh+eXX35xzzt06JAnS5Ysnp49e/q/tmLFip6hQ4e6z+vUqeO5//77/Y8VLlzY06JFiwTfq1GjRp7LLrss1W187LHHXBtnzJhx2mPx8fEJfo4tW7YkeHzhwoXuuH0MbIcdGz9+fILnHjx40BMbG+v597//neD4s88+64mKivJs27bN7W/dutWdi6eeeirB83744QdP1qxZTzsOAECo+PrHFStWJPucfPnyea644gr3+eOPP+6e7zNy5Ei3b9cIyfH1tRdeeKG7XvB599133fHRo0f7jx09evS0rx8+fHiCftb06dMnQTt8FixY4I7fe++9yV4TGHtOTEyMZ9OmTf5j3333nTs+ZsyYZH8WIBKQXg6EiN2JtvQsS/Oy0V9LH5s5c6a/+rjdIba73L652zbSbCnlVnTNWME0313vDRs2uJHftEotf++999yI+Q033HDaY+e6rIndqe/evXuCY5Yqb3fN3333Xevl/cctXc5G9C0Vzthdc0tts1FuOw++zdLpypcvr4ULF55TmwAACAXr85OrYu7L3LIaL9b3nYlli9n1gk+nTp1UvHhxVxTNxzfibY4cOeL6T7uWsH7XMsVSck1gff/jjz9+1msCu7a5+OKL/ft2HWN9/c8//3zW7wNkZgTdQIjY3ClL47KAce3ata5D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" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + } + ], + "execution_count": 321 }, { "cell_type": "markdown", @@ -264,8 +415,8 @@ "shell.execute_reply.started": "2026-03-06T11:51:29.490084Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.127978Z", - "start_time": "2026-04-01T11:04:34.119621Z" + "end_time": "2026-04-01T11:08:37.180064Z", + "start_time": "2026-04-01T11:08:37.176417Z" } }, "source": [ @@ -274,8 +425,17 @@ "print(\"x_pts:\", x_pts2.values)\n", "print(\"y_pts:\", y_pts2.values)" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "x_pts: [ 0. 50. 100. 150.]\n", + "y_pts: [ 0. 55. 130. 225.]\n" + ] + } + ], + "execution_count": 322 }, { "cell_type": "code", @@ -288,8 +448,8 @@ "shell.execute_reply.started": "2026-03-06T11:51:29.501307Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.296782Z", - "start_time": "2026-04-01T11:04:34.145513Z" + "end_time": "2026-04-01T11:08:37.578537Z", + "start_time": "2026-04-01T11:08:37.187530Z" } }, "source": [ @@ -310,7 +470,7 @@ "m2.add_objective(fuel.sum())" ], "outputs": [], - "execution_count": null + "execution_count": 323 }, { "cell_type": "code", @@ -323,15 +483,60 @@ "shell.execute_reply.started": "2026-03-06T11:51:29.604427Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.409396Z", - "start_time": "2026-04-01T11:04:34.301301Z" + "end_time": "2026-04-01T11:08:37.626072Z", + "start_time": "2026-04-01T11:08:37.583238Z" } }, "source": [ "m2.solve(reformulate_sos=\"auto\");" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Set parameter Username\n", + "Academic license - for non-commercial use only - expires 2026-12-18\n", + "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-ni11iy3k.lp\n", + "Reading time = 0.00 seconds\n", + "obj: 30 rows, 24 columns, 69 nonzeros\n", + "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n", + "\n", + "CPU model: Apple M3\n", + "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n", + "\n", + "Optimize a model with 30 rows, 24 columns and 69 nonzeros (Min)\n", + "Model fingerprint: 0x20378670\n", + "Model has 3 linear objective coefficients\n", + "Variable types: 15 continuous, 9 integer (9 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 1e+02]\n", + " Objective range [1e+00, 1e+00]\n", + " Bounds range [1e+00, 2e+02]\n", + " RHS range [5e+01, 1e+02]\n", + "\n", + "Presolve removed 30 rows and 24 columns\n", + "Presolve time: 0.00s\n", + "Presolve: All rows and columns removed\n", + "\n", + "Explored 0 nodes (0 simplex iterations) in 0.00 seconds (0.00 work units)\n", + "Thread count was 1 (of 8 available processors)\n", + "\n", + "Solution count 1: 323 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 3.230000000000e+02, best bound 3.230000000000e+02, gap 0.0000%\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Dual values of MILP couldn't be parsed\n" + ] + } + ], + "execution_count": 324 }, { "cell_type": "code", @@ -344,15 +549,78 @@ "shell.execute_reply.started": "2026-03-06T11:51:29.681822Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.428933Z", - "start_time": "2026-04-01T11:04:34.414748Z" + "end_time": "2026-04-01T11:08:37.636391Z", + "start_time": "2026-04-01T11:08:37.631610Z" } }, "source": [ "m2.solution[[\"power\", \"fuel\"]].to_pandas()" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "data": { + "text/plain": [ + " power fuel\n", + "time \n", + "1 80.0 100.0\n", + "2 120.0 168.0\n", + "3 50.0 55.0" + ], + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
powerfuel
time
180.0100.0
2120.0168.0
350.055.0
\n", + "
" + ] + }, + "execution_count": 325, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 325 }, { "cell_type": "code", @@ -365,16 +633,30 @@ "shell.execute_reply.started": "2026-03-06T11:51:29.699334Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.647218Z", - "start_time": "2026-04-01T11:04:34.448797Z" + "end_time": "2026-04-01T11:08:37.743315Z", + "start_time": "2026-04-01T11:08:37.644492Z" } }, "source": [ "bp2 = linopy.breakpoints({\"power\": x_pts2.values, \"fuel\": y_pts2.values}, dim=\"var\")\n", "plot_pwl_results(m2, bp2, demand2, color=\"C1\")" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "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GTdunKdIkSKexx57LGSOx0bH/+2339xif/5efvll93/faN6pKX/r1q09derU8cybN88ze/ZsN9r+LbfcEnTHExcX57n22ms9ZcqU8SxevDjJ34nY2NiQO56UJB+9PNiOBwiUtP4uITyl5l4RkeGJJ55wo9bb/Zvd39i6zezxww8/BLpoESOig24zbNgwF8hly5bNTSE2d+5cTyiwi2hKy6hRo/z7WKBw3333uSkBLNi77rrr3B/bUA26Q/F4vv32W89FF13kKneqVq3qefvtt5M8b9NU9e/f31O8eHG3T/PmzT2rVq3yBKP9+/e7n4f9vuTIkcNz3nnneZ588skkAVywH8/06dNT/L2xCoXUln/Xrl0uiMuTJ48nX758nq5du7ob3GA7HruwnurvhL0u1I4ntUF3MB0PEChp/V1CeErNvSIig035atdMi3eKFi3q7m8IuDNXlP0T6NZ2AAAAAADCUcT26QYAAAAAIKMRdAMAAAAAkEEIugEAAAAAyCAE3QAAAAAAZBCCbgAAAAAAMghBNwAAAAAAGYSgGwAAAACADELQDQAAAABABiHoBgAAAELYHXfcoQ4dOgS6GABOgaAbCJOLbVRUlFuyZcumCy64QE8//bSOHz8e6KIBAIBz4Lu+n2oZOHCgXnvtNY0ePZrzDASpLIEuAID00bp1a40aNUqxsbGaMGGC7r//fmXNmlV9+vQJ6CmOi4tzFQEAACDttm7d6v//Z599pgEDBmjVqlX+bXny5HELgOBFSzcQJrJnz64SJUqofPny6tGjh1q0aKFvvvlGe/bs0e23366CBQsqV65catOmjVavXu1e4/F4VLRoUX3xxRf+96ldu7ZKlizpX589e7Z778OHD7v1vXv36q677nKvy5cvn5o1a6YlS5b497cad3uPd999VxUrVlSOHDky9TwAABBO7NruW/Lnz+9atxNvs4A7eXp5kyZN9MADD6hXr17u+l+8eHG98847OnTokLp27aq8efO6rLiJEycm+axly5a5+wR7T3vNbbfdpn/++ScARw2EF4JuIEzlzJnTtTLbhXjhwoUuAJ8zZ44LtK+++modO3bMXbivuOIKzZgxw73GAvSVK1fqyJEj+uOPP9y2mTNnqn79+i5gNzfccIN27NjhLtSLFi3SxRdfrObNm2v37t3+z16zZo2+/PJLjRs3TosXLw7QGQAAIHJ98MEHKlKkiObPn+8CcKuQt2v4pZdeql9//VUtW7Z0QXXiSnWrSK9Tp467b5g0aZK2b9+uG2+8MdCHAoQ8gm4gzFhQ/eOPP2ry5MkqV66cC7at1fnyyy9XrVq19PHHH2vz5s366quv/LXhvqB71qxZ7mKbeJs9Xnnllf5Wb7t4jx07VvXq1VOlSpX00ksvqUCBAklayy3Y//DDD9171axZMyDnAQCASGbX/H79+rlrtXU1s8wzC8K7d+/utlma+q5du/T777+7/d944w133R48eLCqVq3q/v/+++9r+vTp+vPPPwN9OEBII+gGwsR3333n0sHsomqpYTfddJNr5c6SJYsaNmzo369w4cKqUqWKa9E2FlCvWLFCO3fudK3aFnD7gm5rDf/ll1/curE08oMHD7r38PUhs2XdunVau3at/zMsxd3SzwEAQGAkrvSOiYlx1+4aNWr4t1n6uLHsNd813gLsxNd3C75N4ms8gLRjIDUgTDRt2lQjRoxwg5aVKlXKBdvWyn0mdgEuVKiQC7htee6551wfsaFDh2rBggUu8LZUNGMBt/X39rWCJ2at3T65c+dO56MDAABpYYOpJmZdyhJvs3WTkJDgv8a3a9fOXf+TSzzWC4C0I+gGwoQFujYoSmLVqlVz04bNmzfPHzhbKpmNelq9enX/RddSz7/++mstX75cjRs3dv23bRT0t956y6WR+4Jo67+9bds2F9BXqFAhAEcJAAAygl3jbTwWu77bdR5A+iG9HAhj1merffv2rv+W9ce21LFbb71VpUuXdtt9LH38k08+caOOWzpZdHS0G2DN+n/7+nMbGxG9UaNGboTUH374QX///bdLP3/yySfdoCsAACA02VSjNijqLbfc4jLdLKXcxoex0c7j4+MDXTwgpBF0A2HO5u6uW7eurrnmGhcw20BrNo934hQzC6ztgurru23s/8m3Wau4vdYCcrsIV65cWTfffLPWr1/v7xsGAABCj3VN+/nnn92130Y2t+5nNuWYdR+zyngAZy/KY3fgAAAAAAAg3VFtBQAAAABABiHoBgAAAAAggxB0AwAAAACQQQi6AQAAAADIIATdAAAAAABkEIJuAAAAAAAyCEE3AAAAAAAZhKAbAAAAAIAMQtANAAAAAEAGIegGAAAAACCDEHQDAAAAAJBBCLoBAAAAAFDG+H/6uPx+5zqtFAAAAABJRU5ErkJggg==" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + } + ], + "execution_count": 326 }, { "cell_type": "markdown", @@ -403,8 +685,8 @@ "shell.execute_reply.started": "2026-03-06T11:51:29.852387Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.655170Z", - "start_time": "2026-04-01T11:04:34.651291Z" + "end_time": "2026-04-01T11:08:37.762965Z", + "start_time": "2026-04-01T11:08:37.758436Z" } }, "source": [ @@ -415,8 +697,25 @@ "print(\"x segments:\\n\", x_seg.to_pandas())\n", "print(\"y segments:\\n\", y_seg.to_pandas())" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "x segments:\n", + " _breakpoint 0 1\n", + "_segment \n", + "0 0.0 0.0\n", + "1 50.0 80.0\n", + "y segments:\n", + " _breakpoint 0 1\n", + "_segment \n", + "0 0.0 0.0\n", + "1 125.0 200.0\n" + ] + } + ], + "execution_count": 327 }, { "cell_type": "code", @@ -429,8 +728,8 @@ "shell.execute_reply.started": "2026-03-06T11:51:29.866931Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.721948Z", - "start_time": "2026-04-01T11:04:34.662824Z" + "end_time": "2026-04-01T11:08:37.845482Z", + "start_time": "2026-04-01T11:08:37.775373Z" } }, "source": [ @@ -451,7 +750,7 @@ "m3.add_objective((cost + 10 * backup).sum())" ], "outputs": [], - "execution_count": null + "execution_count": 328 }, { "cell_type": "code", @@ -464,15 +763,75 @@ "shell.execute_reply.started": "2026-03-06T11:51:29.955741Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.781046Z", - "start_time": "2026-04-01T11:04:34.724468Z" + "end_time": "2026-04-01T11:08:37.920203Z", + "start_time": "2026-04-01T11:08:37.848081Z" } }, "source": [ "m3.solve(reformulate_sos=\"auto\")" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Set parameter Username\n", + "Academic license - for non-commercial use only - expires 2026-12-18\n", + "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-1yu_ivcs.lp\n", + "Reading time = 0.00 seconds\n", + "obj: 18 rows, 27 columns, 48 nonzeros\n", + "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n", + "\n", + "CPU model: Apple M3\n", + "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n", + "\n", + "Optimize a model with 18 rows, 27 columns and 48 nonzeros (Min)\n", + "Model fingerprint: 0x8ec14c73\n", + "Model has 6 linear objective coefficients\n", + "Model has 6 SOS constraints\n", + "Variable types: 21 continuous, 6 integer (6 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 2e+02]\n", + " Objective range [1e+00, 1e+01]\n", + " Bounds range [1e+00, 8e+01]\n", + " RHS range [1e+00, 9e+01]\n", + "\n", + "Presolve removed 15 rows and 22 columns\n", + "Presolve time: 0.00s\n", + "Presolved: 3 rows, 5 columns, 8 nonzeros\n", + "Variable types: 4 continuous, 1 integer (1 binary)\n", + "Found heuristic solution: objective 575.0000000\n", + "\n", + "Root relaxation: cutoff, 0 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + "Explored 1 nodes (0 simplex iterations) in 0.01 seconds (0.00 work units)\n", + "Thread count was 8 (of 8 available processors)\n", + "\n", + "Solution count 1: 575 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 5.750000000000e+02, best bound 5.750000000000e+02, gap 0.0000%\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Dual values of MILP couldn't be parsed\n" + ] + }, + { + "data": { + "text/plain": [ + "('ok', 'optimal')" + ] + }, + "execution_count": 329, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 329 }, { "cell_type": "code", @@ -485,15 +844,83 @@ "shell.execute_reply.started": "2026-03-06T11:51:30.028095Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.788056Z", - "start_time": "2026-04-01T11:04:34.783503Z" + "end_time": "2026-04-01T11:08:37.935150Z", + "start_time": "2026-04-01T11:08:37.929245Z" } }, "source": [ "m3.solution[[\"power\", \"cost\", \"backup\"]].to_pandas()" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "data": { + "text/plain": [ + " power cost backup\n", + "time \n", + "1 0.0 0.0 10.0\n", + "2 70.0 175.0 0.0\n", + "3 80.0 200.0 10.0" + ], + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
powercostbackup
time
10.00.010.0
270.0175.00.0
380.0200.010.0
\n", + "
" + ] + }, + "execution_count": 330, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 330 }, { "cell_type": "markdown", @@ -892,8 +1319,8 @@ "shell.execute_reply.started": "2026-03-06T11:51:30.043484Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.821083Z", - "start_time": "2026-04-01T11:04:34.795038Z" + "end_time": "2026-04-01T11:08:37.974567Z", + "start_time": "2026-04-01T11:08:37.947618Z" } }, "source": [ @@ -916,7 +1343,7 @@ "m4.add_objective(-fuel.sum())" ], "outputs": [], - "execution_count": null + "execution_count": 331 }, { "cell_type": "code", @@ -929,15 +1356,59 @@ "shell.execute_reply.started": "2026-03-06T11:51:30.113810Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.853024Z", - "start_time": "2026-04-01T11:04:34.823664Z" + "end_time": "2026-04-01T11:08:38.006772Z", + "start_time": "2026-04-01T11:08:37.980912Z" } }, "source": [ "m4.solve(reformulate_sos=\"auto\")" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Set parameter Username\n", + "Academic license - for non-commercial use only - expires 2026-12-18\n", + "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-gtsjz8uh.lp\n", + "Reading time = 0.00 seconds\n", + "obj: 12 rows, 6 columns, 21 nonzeros\n", + "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n", + "\n", + "CPU model: Apple M3\n", + "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n", + "\n", + "Optimize a model with 12 rows, 6 columns and 21 nonzeros (Min)\n", + "Model fingerprint: 0x0a213b23\n", + "Model has 3 linear objective coefficients\n", + "Coefficient statistics:\n", + " Matrix range [8e-01, 1e+00]\n", + " Objective range [1e+00, 1e+00]\n", + " Bounds range [1e+02, 1e+02]\n", + " RHS range [1e+01, 1e+02]\n", + "\n", + "Presolve removed 12 rows and 6 columns\n", + "Presolve time: 0.00s\n", + "Presolve: All rows and columns removed\n", + "Iteration Objective Primal Inf. Dual Inf. Time\n", + " 0 -2.3250000e+02 0.000000e+00 0.000000e+00 0s\n", + "\n", + "Solved in 0 iterations and 0.00 seconds (0.00 work units)\n", + "Optimal objective -2.325000000e+02\n" + ] + }, + { + "data": { + "text/plain": [ + "('ok', 'optimal')" + ] + }, + "execution_count": 332, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 332 }, { "cell_type": "code", @@ -950,15 +1421,78 @@ "shell.execute_reply.started": "2026-03-06T11:51:30.171993Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.865733Z", - "start_time": "2026-04-01T11:04:34.861523Z" + "end_time": "2026-04-01T11:08:38.016635Z", + "start_time": "2026-04-01T11:08:38.012572Z" } }, "source": [ "m4.solution[[\"power\", \"fuel\"]].to_pandas()" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "data": { + "text/plain": [ + " power fuel\n", + "time \n", + "1 30.0 37.5\n", + "2 80.0 90.0\n", + "3 100.0 105.0" + ], + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
powerfuel
time
130.037.5
280.090.0
3100.0105.0
\n", + "
" + ] + }, + "execution_count": 333, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 333 }, { "cell_type": "code", @@ -971,16 +1505,30 @@ "shell.execute_reply.started": "2026-03-06T11:51:30.192590Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.953458Z", - "start_time": "2026-04-01T11:04:34.873306Z" + "end_time": "2026-04-01T11:08:38.127204Z", + "start_time": "2026-04-01T11:08:38.036942Z" } }, "source": [ "bp4 = linopy.breakpoints({\"power\": x_pts4.values, \"fuel\": y_pts4.values}, dim=\"var\")\n", "plot_pwl_results(m4, bp4, demand4, color=\"C4\")" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAFUCAYAAAA57l+/AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/TGe4hAAAACXBIWXMAAA9hAAAPYQGoP6dpAABhhklEQVR4nO3dCZzN1f/H8fcsZux7DFmTQgkhkchSiKK0/rVJlEjy+0UUUiH6/bRISGX5/UplScsvSrYWY0+btZJk39cYZu7/8Tm3O90Zgxlm5s699/V8PL5mvt/7vXfO/c645/s553POifB4PB4BAAAAAIBMF5n5LwkAAAAAAAi6AQAAAADIQvR0AwAAAACQRQi6AQAAAADIIgTdAAAAAABkEYJuAAAAAACyCEE3AAAAAABZhKAbAAAAAIAsQtANAAAAAEAWIegGAAAAAuzpp59WRESEgp29h+7duwe6GECOQtANZJMJEya4isi35c6dWxdddJGrmLZv3+7OWbJkiXvsxRdfPOn5bdu2dY+NHz/+pMcaNWqk888/P3n/mmuu0aWXXprF7wgAAGSk3i9durRatGihV155RQcPHsyRF+/TTz91DQAAMg9BN5DNnnnmGf3nP//Rq6++qgYNGmj06NGqX7++jhw5ossvv1x58+bV119/fdLzFi5cqOjoaH3zzTcpjickJGjp0qW66qqrsvFdAACAjNT7Vt8/8sgj7ljPnj1VvXp1ff/998nnPfXUU/rzzz9zRNA9aNCgQBcDCCnRgS4AEG5atWqlOnXquO8feOABFStWTCNGjNCHH36oO++8U/Xq1TspsF67dq127dql//u//zspIF++fLmOHj2qhg0bKhhY44I1LAAAEG71vunbt6/mzp2rNm3a6MYbb9Tq1auVJ08e17BuG4DQQ083EGBNmzZ1Xzds2OC+WvBs6eY///xz8jkWhBcsWFBdunRJDsD9H/M9LzPs27dPjz32mCpUqKDY2FiVKVNG99xzT/LP9KXL/fbbbymeN3/+fHfcvqZOc7eGAUuBt2C7X79+7kbjggsuSPPnW6+//82J+e9//6vatWu7m5KiRYvqjjvu0KZNmzLl/QIAEIi6v3///tq4caOr4041pnv27Nmufi9cuLDy58+viy++2NWjqeve9957zx2Pi4tTvnz5XDCfup786quvdOutt6pcuXKufi9btqyr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9bRkyRKFqqFDh6pu3boqUKCASpQo4dZSXbt2bYpzjh49qm7duqlYsWLKnz+/2rdvr+3btytUPf/884qIiFDPnj3D7hps3rxZd911l3ufefLkUfXq1bVs2bLkx226B5uFuFSpUu7x5s2ba/369QoViYmJ6t+/vypWrOjeX6VKlfTss8+69x3K1+DLL7/UDTfcoNKlS7u//RkzZqR4PD3vec+ePerQoYNb/9fWVu7UqZMOHTqkULgGx48fV58+fdz/h3z58rlz7rnnHm3ZsiWkrgFC25n+nyM8pOe+D+Fh9OjRuuyyy1ydZVv9+vU1c+bMQBcrrIR10P3ee++5dUVt5vIVK1aoRo0aatGihXbs2KFQtGDBAhdMLlq0SLNnz3Y3l9ddd51bR9Xnscce08cff6wpU6a48+1G8+abb1YoWrp0qcaOHes+hPyFwzXYu3evrrrqKuXKlct96K5atUr//ve/VaRIkeRzhg8frldeeUVjxozR4sWLXQBi/z+sUSIUDBs2zFVCr776qlavXu327T2PHDkypK+B/X+3zzprcExLet6zBZs//fST+xz55JNP3A1+ly5dFArX4MiRI64+sAYZ+zp9+nR3k3rjjTemOC/YrwFC25n+nyM8pOe+D+GhTJkyrqNp+fLlroOladOmatu2ravHkE08YeyKK67wdOvWLXk/MTHRU7p0ac/QoUM94WDHjh3WpedZsGCB29+3b58nV65cnilTpiSfs3r1andOfHy8J5QcPHjQU7lyZc/s2bM9jRs39jz66KNhdQ369Onjadiw4SkfT0pK8sTFxXleeOGF5GN2bWJjYz2TJ0/2hILWrVt77r///hTHbr75Zk+HDh3C5hrY3/UHH3yQvJ+e97xq1Sr3vKVLlyafM3PmTE9ERIRn8+bNnmC/BmlZsmSJO2/jxo0heQ0Q2tLzNw5PWN73IbwVKVLE88YbbwS6GGEjbHu6ExISXGuPpU76REZGuv34+HiFg/3797uvRYsWdV/telgrqP81qVKlisqVKxdy18Raflu3bp3ivYbTNfjoo49Up04d3XrrrS7lrFatWho3blzy4xs2bNC2bdtSXIdChQq5IRihch0aNGigOXPmaN26dW7/u+++09dff61WrVqFzTVILT3v2b5aOrX9/fjY+fb5aT3jofpZaSm69r7D9RoACL37PoQnG1737rvvuowHSzNH9ohWmNq1a5f7oytZsmSK47a/Zs0ahbqkpCQ3jtlSjC+99FJ3zG62Y2Jikm8s/a+JPRYq7IPG0kYtvTy1cLkGv/76q0uttuEV/fr1c9eiR48e7r3fe++9ye81rf8foXIdnnjiCR04cMA1qkRFRbnPg8GDB7u0YRMO1yC19Lxn+2oNNf6io6PdTVwoXhdLq7cx3nfeeacbBxeO1wBAaN73Ibz88MMPLsi2es3mLPrggw9UrVq1QBcrbIRt0B3urKf3xx9/dD174WTTpk169NFH3dgmmzwvnCtf66UbMmSI27eebvt7sHG8FnSHg/fff19vv/223nnnHV1yySVauXKluyGxiYfC5Rrg9Czr5bbbbnOTy1kjFQAEq3C978PfLr74YnevYxkPU6dOdfc6Nu6fwDt7hG16efHixV3vVupZqW0/Li5Ooax79+5u4p958+a5iRV87H1b2v2+fftC9ppY+rhNlHf55Ze7ninb7APHJo6y761HL9SvgbGZqVN/yFatWlW///67+973XkP5/8fjjz/uervvuOMON1P13Xff7SbRs9lew+UapJae92xfU082eeLECTebdyhdF1/AvXHjRtdI5+vlDqdrACC07/sQXiyb8cILL1Tt2rXdvY5Ntvjyyy8HulhhIzKc//Dsj87GdPr3/tl+qI5vsN4a++C1dJK5c+e6pZL82fWw2az9r4nN2muBWKhck2bNmrn0Gmvp823W42spxb7vQ/0aGEsvS71siI1tLl++vPve/jYsePC/DpaKbeNVQ+U62CzVNgbXnzXE2edAuFyD1NLznu2rNUpZA5aPfZ7YdbOx36EUcNtSaV988YVbVs9fOFwDAKF/34fwZnXWsWPHAl2M8OEJY++++66blXfChAluNtouXbp4Chcu7Nm2bZsnFHXt2tVTqFAhz/z58z1bt25N3o4cOZJ8zkMPPeQpV66cZ+7cuZ5ly5Z56tev77ZQ5j97ebhcA5uNOTo62jN48GDP+vXrPW+//bYnb968nv/+97/J5zz//PPu/8OHH37o+f777z1t27b1VKxY0fPnn396QsG9997rOf/88z2ffPKJZ8OGDZ7p06d7ihcv7undu3dIXwObuf/bb791m1UBI0aMcN/7ZuZOz3tu2bKlp1atWp7Fixd7vv76a7cSwJ133ukJhWuQkJDgufHGGz1lypTxrFy5MsVn5bFjx0LmGiC0nen/OcJDeu77EB6eeOIJN2u93e9Y3W77tuLG559/HuiihY2wDrrNyJEjXYAVExPjlhBbtGiRJ1RZxZvWNn78+ORz7Mb64YcfdssIWBB20003uQ/ocAq6w+UafPzxx55LL73UNTxVqVLF8/rrr6d43JaP6t+/v6dkyZLunGbNmnnWrl3rCRUHDhxwv3f7/587d27PBRdc4HnyySdTBFaheA3mzZuX5ueANUKk9z3v3r3bBZj58+f3FCxY0NOxY0d3kx8K18BuSE71WWnPC5VrgNB2pv/nCA/pue9DeLAlUsuXL+/infPOO8/V7QTc2SvC/gl0bzsAAAAAAKEobMd0AwAAAACQ1Qi6AQAAAADIIgTdAAAAAABkEYJuAAAAAACyCEE3AAAAAABZhKAbAAAAAIAsQtANAAAAAEAWIegGAAAAACCLEHQDAAAAQey+++5Tu3btAl0MAKdA0A2ESGUbERHhtpiYGF144YV65plndOLEiUAXDQAAnANf/X6q7emnn9bLL7+sCRMmcJ2BHCo60AUAkDlatmyp8ePH69ixY/r000/VrVs35cqVS3379g3oJU5ISHANAQAAIOO2bt2a/P17772nAQMGaO3atcnH8ufP7zYAORc93UCIiI2NVVxcnMqXL6+uXbuqefPm+uijj7R3717dc889KlKkiPLmzatWrVpp/fr17jkej0fnnXeepk6dmvw6NWvWVKlSpZL3v/76a/faR44ccfv79u3TAw884J5XsGBBNW3aVN99913y+dbibq/xxhtvqGLFisqdO3e2XgcAAEKJ1e2+rVChQq532/+YBdyp08uvueYaPfLII+rZs6er/0uWLKlx48bp8OHD6tixowoUKOCy4mbOnJniZ/3444/uPsFe055z9913a9euXQF410BoIegGQlSePHlcL7NVxMuWLXMBeHx8vAu0r7/+eh0/ftxV3I0aNdL8+fPdcyxAX716tf7880+tWbPGHVuwYIHq1q3rAnZz6623aseOHa6iXr58uS6//HI1a9ZMe/bsSf7ZP//8s6ZNm6bp06dr5cqVAboCAACEr4kTJ6p48eJasmSJC8CtQd7q8AYNGmjFihW67rrrXFDt36huDem1atVy9w2zZs3S9u3bddtttwX6rQBBj6AbCDEWVH/xxRf67LPPVK5cORdsW6/z1VdfrRo1aujtt9/W5s2bNWPGjOTWcF/Q/eWXX7rK1v+YfW3cuHFyr7dV3lOmTFGdOnVUuXJl/etf/1LhwoVT9JZbsD9p0iT3WpdddllArgMAAOHM6vynnnrK1dU21MwyzywI79y5sztmaeq7d+/W999/785/9dVXXb09ZMgQValSxX3/1ltvad68eVq3bl2g3w4Q1Ai6gRDxySefuHQwq1QtNez22293vdzR0dGqV69e8nnFihXTxRdf7Hq0jQXUq1at0s6dO12vtgXcvqDbesMXLlzo9o2lkR86dMi9hm8MmW0bNmzQL7/8kvwzLMXd0s8BAEBg+Dd6R0VFubq7evXqyccsfdxY9pqvjrcA279+t+Db+NfxADKOidSAENGkSRONHj3aTVpWunRpF2xbL/eZWAVctGhRF3DbNnjwYDdGbNiwYVq6dKkLvC0VzVjAbeO9fb3g/qy32ydfvnyZ/O4AAEBG2GSq/mxImf8x2zdJSUnJdfwNN9zg6v/U/Od6AZBxBN1AiLBA1yZF8Ve1alW3bNjixYuTA2dLJbNZT6tVq5Zc6Vrq+YcffqiffvpJDRs2dOO3bRb0sWPHujRyXxBt47e3bdvmAvoKFSoE4F0CAICsYHW8zcdi9bvV8wAyD+nlQAizMVtt27Z147dsPLaljt111106//zz3XEfSx+fPHmym3Xc0skiIyPdBGs2/ts3ntvYjOj169d3M6R+/vnn+u2331z6+ZNPPukmXQEAAMHJlhq1SVHvvPNOl+lmKeU2P4zNdp6YmBjo4gFBjaAbCHG2dnft2rXVpk0bFzDbRGu2jrd/ipkF1lah+sZuG/s+9THrFbfnWkBulfBFF12kO+64Qxs3bkweGwYAAIKPDU375ptvXN1vM5vb8DNbcsyGj1ljPICzF+GxO3AAAAAAAJDpaLYCAAAAACCLEHQDAAAAAJBFCLoBAAAAAMgiBN0AAAAAAGQRgm4AAAAAALIIQTcAAAAAAFmEoBsAAAAAgCxC0A0AAAAAQBYh6AYAAAAAIIsQdAMAAAAAkEUIugEAAAAAyCIE3QAAAAAAKGv8P/JlaiIU4rmcAAAAAElFTkSuQmCC" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + } + ], + "execution_count": 334 }, { "cell_type": "markdown", @@ -1004,8 +1552,8 @@ "shell.execute_reply.started": "2026-03-06T11:51:30.345513Z" }, "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.959715Z", - "start_time": "2026-04-01T11:04:34.956645Z" + "end_time": "2026-04-01T11:08:38.135897Z", + "start_time": "2026-04-01T11:08:38.133078Z" } }, "source": [ @@ -1014,8 +1562,16 @@ "y_pts5 = linopy.breakpoints(slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0)\n", "print(\"y breakpoints from slopes:\", y_pts5.values)" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "y breakpoints from slopes: [ 0. 55. 130. 225.]\n" + ] + } + ], + "execution_count": 335 }, { "cell_type": "markdown", @@ -1934,8 +2490,8 @@ "cell_type": "code", "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:04:34.967501Z", - "start_time": "2026-04-01T11:04:34.964517Z" + "end_time": "2026-04-01T11:08:38.148433Z", + "start_time": "2026-04-01T11:08:38.145204Z" } }, "source": [ @@ -1949,15 +2505,24 @@ "print(\"Power breakpoints:\", x_pts6.values)\n", "print(\"Fuel breakpoints: \", y_pts6.values)" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Power breakpoints: [ 30. 60. 100.]\n", + "Fuel breakpoints: [ 40. 90. 170.]\n" + ] + } + ], + "execution_count": 336 }, { "cell_type": "code", "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:04:35.057769Z", - "start_time": "2026-04-01T11:04:34.973035Z" + "end_time": "2026-04-01T11:08:38.256777Z", + "start_time": "2026-04-01T11:08:38.161249Z" } }, "source": [ @@ -1987,50 +2552,203 @@ "m6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())" ], "outputs": [], - "execution_count": null + "execution_count": 337 }, { "cell_type": "code", "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:04:35.162579Z", - "start_time": "2026-04-01T11:04:35.060891Z" + "end_time": "2026-04-01T11:08:38.332350Z", + "start_time": "2026-04-01T11:08:38.263473Z" } }, "source": [ "m6.solve(reformulate_sos=\"auto\")" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Set parameter Username\n", + "Academic license - for non-commercial use only - expires 2026-12-18\n", + "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-k90jz3qk.lp\n", + "Reading time = 0.00 seconds\n", + "obj: 27 rows, 24 columns, 66 nonzeros\n", + "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n", + "\n", + "CPU model: Apple M3\n", + "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n", + "\n", + "Optimize a model with 27 rows, 24 columns and 66 nonzeros (Min)\n", + "Model fingerprint: 0x4b0d5f70\n", + "Model has 9 linear objective coefficients\n", + "Variable types: 15 continuous, 9 integer (9 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 8e+01]\n", + " Objective range [1e+00, 5e+01]\n", + " Bounds range [1e+00, 1e+02]\n", + " RHS range [2e+01, 7e+01]\n", + "\n", + "Found heuristic solution: objective 675.0000000\n", + "Presolve removed 24 rows and 19 columns\n", + "Presolve time: 0.00s\n", + "Presolved: 3 rows, 5 columns, 10 nonzeros\n", + "Found heuristic solution: objective 485.0000000\n", + "Variable types: 3 continuous, 2 integer (2 binary)\n", + "\n", + "Root relaxation: objective 3.516667e+02, 3 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + " 0 0 351.66667 0 1 485.00000 351.66667 27.5% - 0s\n", + "* 0 0 0 358.3333333 358.33333 0.00% - 0s\n", + "\n", + "Explored 1 nodes (5 simplex iterations) in 0.02 seconds (0.00 work units)\n", + "Thread count was 8 (of 8 available processors)\n", + "\n", + "Solution count 3: 358.333 485 675 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 3.583333333333e+02, best bound 3.583333333333e+02, gap 0.0000%\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Dual values of MILP couldn't be parsed\n" + ] + }, + { + "data": { + "text/plain": [ + "('ok', 'optimal')" + ] + }, + "execution_count": 338, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 338 }, { "cell_type": "code", "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:04:35.181403Z", - "start_time": "2026-04-01T11:04:35.176031Z" + "end_time": "2026-04-01T11:08:38.341128Z", + "start_time": "2026-04-01T11:08:38.336172Z" } }, "source": [ "m6.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "data": { + "text/plain": [ + " commit power fuel backup\n", + "time \n", + "1 0.0 0.0 0.000000 15.0\n", + "2 1.0 70.0 110.000000 0.0\n", + "3 1.0 50.0 73.333333 0.0" + ], + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
commitpowerfuelbackup
time
10.00.00.00000015.0
21.070.0110.0000000.0
31.050.073.3333330.0
\n", + "
" + ] + }, + "execution_count": 339, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 339 }, { "cell_type": "code", "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:04:35.285661Z", - "start_time": "2026-04-01T11:04:35.189219Z" + "end_time": "2026-04-01T11:08:38.440499Z", + "start_time": "2026-04-01T11:08:38.353813Z" } }, "source": [ "bp6 = linopy.breakpoints({\"power\": x_pts6.values, \"fuel\": y_pts6.values}, dim=\"var\")\n", "plot_pwl_results(m6, bp6, demand6, color=\"C2\")" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "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vHnzgH4PAAEUcyZxx1k1cyCNeuCBB9SyZctgnwYApFoE3eebx7fxR2nFV56vth1A06ZNc3Pip0yZoh07duiKK65QSlaJfeedd/TCCy8onIwbN04vv/xyoo+3Je2S2iHy4IMPatmyZW4JPQCpiE2jWvyRNOmJCxwYIeUs6lk+DGHPglNrJ+xh61cXKlRIN954oz7++GOdOZPIDhwAQJoTsCXDUrUgLA2zYcMGt1xU7dopf+P24Ycfuu9bsmTJS3qfkydPKmPGjEotLKsg0Ox63HPPPXr33XdVty7zPYFUwf72T+oirf/es12gkrRnzdkn/WuaRHi+NH2NImrwadq0qUaMGOGqsO/atct1qj/xxBP66quvNGnSJKVPz60XAIQbRroTWhombuEc79Iw9nwAesa7dOmiLVu2uN7xUqVKuf329e233451rKWKW8q4l6Vg//e//1WBAgWUM2dONWzYUL/++muSvr9Vk48vBfr06dPq3LmzcuXKpfz587sUdP8ienZ+NlJ8//33u+/tTU//6aefXIBpy4gUL15cjz/+uI4cOeJ73f/93/+pRo0absm4qKgoF5R616mOj61j3axZM11//fXu5/WOONt5W2dB5syZXWbAnDlzYr3Otq+99lpXMd86NJ577jn3MyWUXm4/T79+/dzotJ2bLYH3/vvv+563Svvmqquuct/fXu9Nh7fvky1bNuXOndud5+bNm32vs2trN1rHjh1Lwm8FQFBYdtPQWp6AO31mqUl/6dF5nmXBchaOfax1xrJcGOKwNsfatqJFi7risc8//7wmTpyoqVOn+lZ5uVDb7Z0aZiPk1hZlz55djz32mAvkBwwY4N7flph89dVXY33vt956S1WqVHHtkbW/9hpb6tXLvr+1U9OnT1elSpXc+1ongWXYedn3sBVl7DhbevXZZ5+9YAFdAMD5hUfQbY3FySMXfhw/KE199jxLw1geeHfPcYl5v0Q2Upba3bdvXxUrVsw1fIsXL070j3b77be7gNUa86VLl7oGvlGjRtq3b1+iXm/HrVq1ygXBcY0aNcr1yC9atMidozXmNiru780331S1atX0yy+/uKDcRuytAW/Tpo1+++03ffHFFy4It+DdfzkYC9btBmPChAkuiLaOh/jYjYml5lla3owZM9xNgNczzzyjp556yn3vWrVqueB279697rlt27bp5ptv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" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + } + ], + "execution_count": 340 }, { "cell_type": "markdown", @@ -2734,8 +3452,8 @@ "cell_type": "code", "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:04:35.298514Z", - "start_time": "2026-04-01T11:04:35.294926Z" + "end_time": "2026-04-01T11:08:38.453545Z", + "start_time": "2026-04-01T11:08:38.450339Z" } }, "source": [ @@ -2751,15 +3469,28 @@ "print(\"CHP breakpoints:\")\n", "print(bp_chp.to_pandas())" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "CHP breakpoints:\n", + "_breakpoint 0 1 2 3\n", + "var \n", + "power 0.0 30.0 60.0 100.0\n", + "fuel 0.0 40.0 85.0 160.0\n", + "heat 0.0 25.0 55.0 95.0\n" + ] + } + ], + "execution_count": 341 }, { "cell_type": "code", "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:04:35.366295Z", - "start_time": "2026-04-01T11:04:35.311584Z" + "end_time": "2026-04-01T11:08:38.518849Z", + "start_time": "2026-04-01T11:08:38.466354Z" } }, "source": [ @@ -2785,45 +3516,171 @@ "m7.add_objective(fuel.sum())" ], "outputs": [], - "execution_count": null + "execution_count": 342 }, { "cell_type": "code", "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:04:35.414186Z", - "start_time": "2026-04-01T11:04:35.376453Z" + "end_time": "2026-04-01T11:08:38.581845Z", + "start_time": "2026-04-01T11:08:38.522785Z" } }, "source": [ "m7.solve(reformulate_sos=\"auto\")" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Set parameter Username\n", + "Academic license - for non-commercial use only - expires 2026-12-18\n", + "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-5535qbzh.lp\n", + "Reading time = 0.00 seconds\n", + "obj: 15 rows, 21 columns, 51 nonzeros\n", + "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n", + "\n", + "CPU model: Apple M3\n", + "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n", + "\n", + "Optimize a model with 15 rows, 21 columns and 51 nonzeros (Min)\n", + "Model fingerprint: 0x508c4706\n", + "Model has 3 linear objective coefficients\n", + "Model has 3 SOS constraints\n", + "Variable types: 21 continuous, 0 integer (0 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 2e+02]\n", + " Objective range [1e+00, 1e+00]\n", + " Bounds range [1e+00, 1e+02]\n", + " RHS range [1e+00, 9e+01]\n", + "\n", + "Presolve removed 15 rows and 21 columns\n", + "Presolve time: 0.00s\n", + "Presolve: All rows and columns removed\n", + "\n", + "Explored 0 nodes (0 simplex iterations) in 0.01 seconds (0.00 work units)\n", + "Thread count was 1 (of 8 available processors)\n", + "\n", + "Solution count 2: 252.917 252.917 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 2.529166651870e+02, best bound 2.529166651870e+02, gap 0.0000%\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Dual values of MILP couldn't be parsed\n" + ] + }, + { + "data": { + "text/plain": [ + "('ok', 'optimal')" + ] + }, + "execution_count": 343, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 343 }, { "cell_type": "code", "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:04:35.425823Z", - "start_time": "2026-04-01T11:04:35.420515Z" + "end_time": "2026-04-01T11:08:38.632933Z", + "start_time": "2026-04-01T11:08:38.620498Z" } }, "source": [ "m7.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas().round(2)" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "data": { + "text/plain": [ + " power fuel heat\n", + "time \n", + "1 20.0 26.67 16.67\n", + "2 60.0 85.00 55.00\n", + "3 90.0 141.25 85.00" + ], + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
powerfuelheat
time
120.026.6716.67
260.085.0055.00
390.0141.2585.00
\n", + "
" + ] + }, + "execution_count": 344, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 344 }, { "cell_type": "code", "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:04:35.522236Z", - "start_time": "2026-04-01T11:04:35.434392Z" + "end_time": "2026-04-01T11:08:38.743684Z", + "start_time": "2026-04-01T11:08:38.645091Z" } }, - "source": "plot_pwl_results(m7, bp_chp, power_dispatch, x_name=\"fuel\")", + "source": [ + "plot_pwl_results(m7, bp_chp, power_dispatch, x_name=\"fuel\")" + ], "outputs": [ { "data": { @@ -2839,7 +3696,7 @@ } } ], - "execution_count": null + "execution_count": 345 }, { "cell_type": "markdown", @@ -2850,8 +3707,8 @@ "cell_type": "code", "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:04:35.530322Z", - "start_time": "2026-04-01T11:04:35.525288Z" + "end_time": "2026-04-01T11:08:38.759346Z", + "start_time": "2026-04-01T11:08:38.752115Z" } }, "source": [ @@ -2867,15 +3724,32 @@ "print(\"Power breakpoints:\\n\", x_gen.to_pandas())\n", "print(\"Fuel breakpoints:\\n\", y_gen.to_pandas())" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Power breakpoints:\n", + " _breakpoint 0 1 2 3\n", + "gen \n", + "gas 0.0 30.0 60.0 100.0\n", + "coal 0.0 50.0 100.0 150.0\n", + "Fuel breakpoints:\n", + " _breakpoint 0 1 2 3\n", + "gen \n", + "gas 0.0 40.0 90.0 180.0\n", + "coal 0.0 55.0 130.0 225.0\n" + ] + } + ], + "execution_count": 346 }, { "cell_type": "code", "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:04:35.618316Z", - "start_time": "2026-04-01T11:04:35.539292Z" + "end_time": "2026-04-01T11:08:38.852492Z", + "start_time": "2026-04-01T11:08:38.765098Z" } }, "source": [ @@ -2896,68 +3770,211 @@ "m8.add_objective(fuel.sum())" ], "outputs": [], - "execution_count": null + "execution_count": 347 }, { "cell_type": "code", "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:04:35.680516Z", - "start_time": "2026-04-01T11:04:35.620789Z" + "end_time": "2026-04-01T11:08:38.923105Z", + "start_time": "2026-04-01T11:08:38.855310Z" } }, "source": [ "m8.solve(reformulate_sos=\"auto\")" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Set parameter Username\n", + "Academic license - for non-commercial use only - expires 2026-12-18\n", + "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-xk807eiy.lp\n", + "Reading time = 0.00 seconds\n", + "obj: 57 rows, 48 columns, 138 nonzeros\n", + "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n", + "\n", + "CPU model: Apple M3\n", + "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n", + "\n", + "Optimize a model with 57 rows, 48 columns and 138 nonzeros (Min)\n", + "Model fingerprint: 0x9060ba6d\n", + "Model has 6 linear objective coefficients\n", + "Variable types: 30 continuous, 18 integer (18 binary)\n", + "Coefficient statistics:\n", + " Matrix range [1e+00, 1e+02]\n", + " Objective range [1e+00, 1e+00]\n", + " Bounds range [1e+00, 2e+02]\n", + " RHS range [6e+01, 1e+02]\n", + "\n", + "Found heuristic solution: objective 357.5000000\n", + "Presolve removed 50 rows and 38 columns\n", + "Presolve time: 0.00s\n", + "Presolved: 7 rows, 10 columns, 23 nonzeros\n", + "Found heuristic solution: objective 340.0000000\n", + "Variable types: 6 continuous, 4 integer (4 binary)\n", + "\n", + "Root relaxation: objective 3.183333e+02, 1 iterations, 0.00 seconds (0.00 work units)\n", + "\n", + " Nodes | Current Node | Objective Bounds | Work\n", + " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", + "\n", + "* 0 0 0 318.3333333 318.33333 0.00% - 0s\n", + "\n", + "Explored 1 nodes (1 simplex iterations) in 0.02 seconds (0.00 work units)\n", + "Thread count was 8 (of 8 available processors)\n", + "\n", + "Solution count 3: 318.333 340 357.5 \n", + "\n", + "Optimal solution found (tolerance 1.00e-04)\n", + "Best objective 3.183333333333e+02, best bound 3.183333333333e+02, gap 0.0000%\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Dual values of MILP couldn't be parsed\n" + ] + }, + { + "data": { + "text/plain": [ + "('ok', 'optimal')" + ] + }, + "execution_count": 348, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 348 }, { "cell_type": "code", "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:04:35.689476Z", - "start_time": "2026-04-01T11:04:35.683696Z" + "end_time": "2026-04-01T11:08:38.943143Z", + "start_time": "2026-04-01T11:08:38.934884Z" } }, "source": [ "m8.solution[[\"power\", \"fuel\"]].to_dataframe().round(2)" ], - "outputs": [], - "execution_count": null + "outputs": [ + { + "data": { + "text/plain": [ + " power fuel\n", + "gen time \n", + "gas 1 30.0 40.00\n", + " 2 30.0 40.00\n", + " 3 10.0 13.33\n", + "coal 1 50.0 55.00\n", + " 2 90.0 115.00\n", + " 3 50.0 55.00" + ], + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
powerfuel
gentime
gas130.040.00
230.040.00
310.013.33
coal150.055.00
290.0115.00
350.055.00
\n", + "
" + ] + }, + "execution_count": 349, + "metadata": {}, + "output_type": "execute_result" + } + ], + "execution_count": 349 }, { "cell_type": "code", "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:04:35.788222Z", - "start_time": "2026-04-01T11:04:35.698204Z" + "end_time": "2026-04-01T11:08:39.047739Z", + "start_time": "2026-04-01T11:08:38.949442Z" } }, - "source": [ - "sol = m8.solution\n", - "fig, axes = plt.subplots(1, 2, figsize=(10, 3.5))\n", - "\n", - "for i, gen in enumerate(gens):\n", - " ax = axes[i]\n", - " xp = x_gen.sel(gen=gen).values\n", - " yp = y_gen.sel(gen=gen).values\n", - " ax.plot(xp, yp, \"o-\", color=f\"C{i}\", label=\"Breakpoints\")\n", - " for t in time:\n", - " ax.plot(\n", - " float(sol[\"power\"].sel(gen=gen, time=t)),\n", - " float(sol[\"fuel\"].sel(gen=gen, time=t)),\n", - " \"D\",\n", - " color=\"black\",\n", - " ms=8,\n", - " )\n", - " ax.set(xlabel=\"Power [MW]\", ylabel=\"Fuel\", title=f\"{gen} heat-rate curve\")\n", - " ax.legend()\n", - "\n", - "plt.tight_layout()" + "source": "sol = m8.solution\nfig, axes = plt.subplots(1, 2, figsize=(10, 3.5))\n\nfor i, gen in enumerate(gens):\n ax = axes[i]\n fuel_bp = y_gen.sel(gen=gen).values\n power_bp = x_gen.sel(gen=gen).values\n ax.plot(fuel_bp, power_bp, \"o-\", color=f\"C{i}\", label=\"Breakpoints\")\n for t in time:\n ax.plot(\n float(sol[\"fuel\"].sel(gen=gen, time=t)),\n float(sol[\"power\"].sel(gen=gen, time=t)),\n \"D\",\n color=\"black\",\n ms=8,\n )\n ax.set(xlabel=\"Fuel\", ylabel=\"Power [MW]\", title=f\"{gen.title()} heat-rate curve\")\n ax.legend()\n\nplt.tight_layout()", + "outputs": [ + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "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UDWnzN6XBPmM/s7k+8Thw4c1tWvXVgHO3d1dU65cOc1nn32W6oLAkt6TBF0JohI8ZcoQmUarf//+TyQulnaunvb7J+bNm6fx8PBQ06GkZYnniewrZpubrcbW7GSrcdCcbDFeWXp+YOjf++XLlzWtW7dWcVVu0MmNu4SEBI09cZB/zN3aTkRERERERGSLOKabiIiIiIiIyESYdBMRERERERGZCJNuIiIiIiIiIhNh0k1ERERERERkIky6iYiIiIiIiEyESTcRERERERGRiTDpJiIiIiIiIjIRJt1EREREREREJsKkm4jMYvv27XBwcEBERATPABERkYVivCZ6fky6iShDvXv3Volx2kdISAiPGhERkYVgvCaybM7m3gEismytWrXCwoULU63Lnz+/2faHiIiInsR4TWS52NJNRJlyc3ODv79/qke/fv3QoUOHVNsNGzYMTZo0SVlOTk7GlClTUKxYMXh4eKBy5cr47bffeLSJiIhMgPGayHKxpZuITEIS7sWLF2Pu3LkoVaoUdu7ciR49eqhW8saNG/OoExERWQDGayLTY9JNRJlat24dPD09U5Zbt26NnDlzZvo9cXFx+Oyzz7B582bUrVtXrStevDh2796NefPmMekmIiIyMsZrIsvFpJuIMtW0aVPMmTMnZVkS7rFjx2b6PVJoLSYmBi+88EKq9fHx8ahatSqPOBERkZExXhNZLibdRJQpSbJLliyZap2joyM0Gk2qdQkJCSn/f/Tokfq6fv16FCxY8IkxZ0RERGRcjNdElotJNxFlmYzLPnnyZKp1x44dg4uLi/p/UFCQSq6vXr3KruRERERmwnhNZBmYdBNRljVr1gxffPEFFi1apMZsS8E0ScJ1Xce9vLwwatQoDB8+XFUxb9CgASIjI7Fnzx7kypULvXr14lEnIiIyMcZrIsvApJuIsqxly5YYP3483nvvPcTGxqJv377o2bMnTpw4kbLNxx9/rO6wS1XUS5cuIXfu3KhWrRo++OADHnEiIqJswHhNZBkcNGkHZhIRERERERGRUTga52WIiIiIiIiIKC0m3UREREREREQmwqSbiIiIiIiIyESYdBMRERERERGZCJNuIiIiIiIiIhNh0k1ERERERERkIky6iYiIiIiIiEyESTcRERERERGRiTDpJiIiIiIiIjIRJt1EREREREREJsKkm4iIiIiIiMhEmHQTERERERERwTT+DyL9jcFaLIqHAAAAAElFTkSuQmCC" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + } ], - "outputs": [], - "execution_count": null + "execution_count": 350 } ], "metadata": { From 03fb7ba8c35025ea814e8fc69fc57bc25a10c26a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:29:35 +0000 Subject: [PATCH 19/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- examples/piecewise-linear-constraints.ipynb | 1158 +------------------ 1 file changed, 61 insertions(+), 1097 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index f7fd39c6..7f6a473a 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -105,7 +105,7 @@ " plt.tight_layout()" ], "outputs": [], - "execution_count": 316 + "execution_count": null }, { "cell_type": "markdown", @@ -139,17 +139,8 @@ "print(\"x_pts:\", x_pts1.values)\n", "print(\"y_pts:\", y_pts1.values)" ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "x_pts: [ 0. 30. 60. 100.]\n", - "y_pts: [ 0. 36. 84. 170.]\n" - ] - } - ], - "execution_count": 317 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -185,7 +176,7 @@ "m1.add_objective(fuel.sum())" ], "outputs": [], - "execution_count": 318 + "execution_count": null }, { "cell_type": "code", @@ -205,73 +196,8 @@ "source": [ "m1.solve(reformulate_sos=\"auto\")" ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set parameter Username\n", - "Academic license - for non-commercial use only - expires 2026-12-18\n", - "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-6f6dxleu.lp\n", - "Reading time = 0.00 seconds\n", - "obj: 12 rows, 18 columns, 39 nonzeros\n", - "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n", - "\n", - "CPU model: Apple M3\n", - "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n", - "\n", - "Optimize a model with 12 rows, 18 columns and 39 nonzeros (Min)\n", - "Model fingerprint: 0x109ede56\n", - "Model has 3 linear objective coefficients\n", - "Model has 3 SOS constraints\n", - "Variable types: 18 continuous, 0 integer (0 binary)\n", - "Coefficient statistics:\n", - " Matrix range [1e+00, 2e+02]\n", - " Objective range [1e+00, 1e+00]\n", - " Bounds range [1e+00, 1e+02]\n", - " RHS range [1e+00, 8e+01]\n", - "\n", - "Presolve removed 8 rows and 13 columns\n", - "Presolve time: 0.00s\n", - "Presolved: 4 rows, 5 columns, 10 nonzeros\n", - "Variable types: 4 continuous, 1 integer (1 binary)\n", - "Found heuristic solution: objective 231.0000000\n", - "\n", - "Root relaxation: cutoff, 2 iterations, 0.00 seconds (0.00 work units)\n", - "\n", - " Nodes | Current Node | Objective Bounds | Work\n", - " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", - "\n", - " 0 0 cutoff 0 231.00000 231.00000 0.00% - 0s\n", - "\n", - "Explored 1 nodes (2 simplex iterations) in 0.01 seconds (0.00 work units)\n", - "Thread count was 8 (of 8 available processors)\n", - "\n", - "Solution count 1: 231 \n", - "\n", - "Optimal solution found (tolerance 1.00e-04)\n", - "Best objective 2.310000000000e+02, best bound 2.310000000000e+02, gap 0.0000%\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Dual values of MILP couldn't be parsed\n" - ] - }, - { - "data": { - "text/plain": [ - "('ok', 'optimal')" - ] - }, - "execution_count": 319, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 319 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -291,71 +217,8 @@ "source": [ "m1.solution[[\"power\", \"fuel\"]].to_pandas()" ], - "outputs": [ - { - "data": { - "text/plain": [ - " power fuel\n", - "time \n", - "1 50.0 68.0\n", - "2 80.0 127.0\n", - "3 30.0 36.0" - ], - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
powerfuel
time
150.068.0
280.0127.0
330.036.0
\n", - "
" - ] - }, - "execution_count": 320, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 320 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -376,22 +239,8 @@ "bp1 = linopy.breakpoints({\"power\": x_pts1.values, \"fuel\": y_pts1.values}, dim=\"var\")\n", "plot_pwl_results(m1, bp1, demand1, color=\"C0\")" ], - "outputs": [ - { - "data": { - "text/plain": [ - "
" - ], - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAFUCAYAAAA57l+/AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/TGe4hAAAACXBIWXMAAA9hAAAPYQGoP6dpAABmTElEQVR4nO3dB3hU1dbG8TcBEjpIB6kqTaVIR5FeVQQBC+KVJlgABe61oKKABdR7BUQEC81PsYCAFZSOBaQoKoI0adItgICEkvmetccZJyGBBDKZSeb/e55Dcs5MJjsnQ/ZZZ6+9dpTH4/EIAAAAAACkuei0f0kAAAAAAEDQDQAAAABAEDHSDQAAAABAkBB0AwAAAAAQJATdAAAAAAAECUE3AAAAAABBQtANAAAAAECQEHQDAAAAABAkBN0AAAAAAAQJQTcAAAAQIkOGDFFUVFSGP//2M/Tt2zfUzQDCEkE3kM4mT57sOibflj17dlWoUMF1VHv37nXPWb58uXts5MiRp319u3bt3GOTJk067bGGDRvqwgsv9O83btxYl19+eZB/IgAAcKZ+vkSJEmrVqpVeeOEF/fnnn2F5sj755BN3AwBA2iPoBkJk2LBh+r//+z+9+OKLuvLKKzVu3DjVr19fR48eVY0aNZQzZ0598cUXp33dV199paxZs+rLL79McPz48eNasWKFrrrqqnT8KQAAwJn6eevf+/Xr5471799fVapU0ffff+9/3qOPPqq//vorLILuoUOHhroZQKaUNdQNACJVmzZtVKtWLff5HXfcoYIFC+r555/X+++/r86dO6tu3bqnBdbr16/Xr7/+qltvvfW0gHzVqlU6duyYGjRooIzAbi7YjQUAADJ7P28GDRqkBQsW6LrrrtP111+vdevWKUeOHO5Gum0AMi9GuoEw0bRpU/dxy5Yt7qMFz5ZuvmnTJv9zLAjPmzevevfu7Q/AAx/zfV1aOHDggAYMGKCyZcsqNjZWJUuW1O233+7/nr70ua1btyb4ukWLFrnj9jFxmrvdGLAUeAu2H374YXfhcdFFFyX5/W3UP/BixbzxxhuqWbOmu0gpUKCAbrnlFu3YsSNNfl4AANKjrx88eLC2bdvm+rTk5nTPnTvX9ef58+dX7ty5VbFiRddvJu5r33nnHXe8WLFiypUrlwvmE/eLn3/+uW688UaVLl3a9eelSpVy/Xvg6Hq3bt00duxY93lgarxPfHy8Ro8e7UbpLV2+cOHCat26tVauXHnazzhr1izX59v3uuyyyzRnzpw0PINAxsRtNSBMbN682X20Ee/A4NlGtC+55BJ/YF2vXj03Cp4tWzaXam4drO+xPHnyqFq1aufdlsOHD+vqq692d+F79Ojh0t0t2P7ggw/0yy+/qFChQql+zd9++83d9bdA+bbbblPRokVdAG2BvKXF165d2/9cuxhZtmyZnnvuOf+xp556yl2o3HTTTS4zYP/+/RozZowL4r/99lt3YQIAQLj717/+5QLlzz77TL169Trt8R9//NHdlK5atapLUbfg1W7AJ85+8/WNFhw/+OCD2rdvn0aNGqXmzZtr9erV7ga1mTZtmssuu/vuu901htWNsf7T+nN7zNx5553atWuXC/YtJT6xnj17upvt1o9bH3zy5EkXzFtfHXiD3K5ZZsyYoXvuucddk9gc9o4dO2r79u3+6xsgInkApKtJkyZ57L/evHnzPPv37/fs2LHD8/bbb3sKFizoyZEjh+eXX35xzzt06JAnS5Ysnp49e/q/tmLFip6hQ4e6z+vUqeO5//77/Y8VLlzY06JFiwTfq1GjRp7LLrss1W187LHHXBtnzJhx2mPx8fEJfo4tW7YkeHzhwoXuuH0MbIcdGz9+fILnHjx40BMbG+v597//neD4s88+64mKivJs27bN7W/dutWdi6eeeirB83744QdP1qxZTzsOAECo+PrHFStWJPucfPnyea644gr3+eOPP+6e7zNy5Ei3b9cIyfH1tRdeeKG7XvB599133fHRo0f7jx09evS0rx8+fHiCftb06dMnQTt8FixY4I7fe++9yV4TGHtOTEyMZ9OmTf5j3333nTs+ZsyYZH8WIBKQXg6EiN2JtvQsS/Oy0V9LH5s5c6a/+rjdIba73L652zbSbCnlVnTNWME0313vDRs2uJHftEotf++999yI+Q033HDaY+e6rIndqe/evXuCY5Yqb3fN3333Xevl/cctXc5G9C0Vzthdc0tts1FuOw++zdLpypcvr4ULF55TmwAACAXr85OrYu7L3LIaL9b3nYlli9n1gk+nTp1UvHhxVxTNxzfibY4cOeL6T7uWsH7XMsVSck1gff/jjz9+1msCu7a5+OKL/ft2HWN9/c8//3zW7wNkZgTdQIjY3ClL47KAce3ata5D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" - }, - "metadata": {}, - "output_type": "display_data", - "jetTransient": { - "display_id": null - } - } - ], - "execution_count": 321 + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -425,17 +274,8 @@ "print(\"x_pts:\", x_pts2.values)\n", "print(\"y_pts:\", y_pts2.values)" ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "x_pts: [ 0. 50. 100. 150.]\n", - "y_pts: [ 0. 55. 130. 225.]\n" - ] - } - ], - "execution_count": 322 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -470,7 +310,7 @@ "m2.add_objective(fuel.sum())" ], "outputs": [], - "execution_count": 323 + "execution_count": null }, { "cell_type": "code", @@ -490,53 +330,8 @@ "source": [ "m2.solve(reformulate_sos=\"auto\");" ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set parameter Username\n", - "Academic license - for non-commercial use only - expires 2026-12-18\n", - "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-ni11iy3k.lp\n", - "Reading time = 0.00 seconds\n", - "obj: 30 rows, 24 columns, 69 nonzeros\n", - "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n", - "\n", - "CPU model: Apple M3\n", - "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n", - "\n", - "Optimize a model with 30 rows, 24 columns and 69 nonzeros (Min)\n", - "Model fingerprint: 0x20378670\n", - "Model has 3 linear objective coefficients\n", - "Variable types: 15 continuous, 9 integer (9 binary)\n", - "Coefficient statistics:\n", - " Matrix range [1e+00, 1e+02]\n", - " Objective range [1e+00, 1e+00]\n", - " Bounds range [1e+00, 2e+02]\n", - " RHS range [5e+01, 1e+02]\n", - "\n", - "Presolve removed 30 rows and 24 columns\n", - "Presolve time: 0.00s\n", - "Presolve: All rows and columns removed\n", - "\n", - "Explored 0 nodes (0 simplex iterations) in 0.00 seconds (0.00 work units)\n", - "Thread count was 1 (of 8 available processors)\n", - "\n", - "Solution count 1: 323 \n", - "\n", - "Optimal solution found (tolerance 1.00e-04)\n", - "Best objective 3.230000000000e+02, best bound 3.230000000000e+02, gap 0.0000%\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Dual values of MILP couldn't be parsed\n" - ] - } - ], - "execution_count": 324 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -556,71 +351,8 @@ "source": [ "m2.solution[[\"power\", \"fuel\"]].to_pandas()" ], - "outputs": [ - { - "data": { - "text/plain": [ - " power fuel\n", - "time \n", - "1 80.0 100.0\n", - "2 120.0 168.0\n", - "3 50.0 55.0" - ], - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
powerfuel
time
180.0100.0
2120.0168.0
350.055.0
\n", - "
" - ] - }, - "execution_count": 325, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 325 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -641,22 +373,8 @@ "bp2 = linopy.breakpoints({\"power\": x_pts2.values, \"fuel\": y_pts2.values}, dim=\"var\")\n", "plot_pwl_results(m2, bp2, demand2, color=\"C1\")" ], - "outputs": [ - { - "data": { - "text/plain": [ - "
" - ], - "image/png": "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GTdunKdIkSKexx57LGSOx0bH/+2339xif/5efvll93/faN6pKX/r1q09derU8cybN88ze/ZsN9r+LbfcEnTHExcX57n22ms9ZcqU8SxevDjJ34nY2NiQO56UJB+9PNiOBwiUtP4uITyl5l4RkeGJJ55wo9bb/Zvd39i6zezxww8/BLpoESOig24zbNgwF8hly5bNTSE2d+5cTyiwi2hKy6hRo/z7WKBw3333uSkBLNi77rrr3B/bUA26Q/F4vv32W89FF13kKneqVq3qefvtt5M8b9NU9e/f31O8eHG3T/PmzT2rVq3yBKP9+/e7n4f9vuTIkcNz3nnneZ588skkAVywH8/06dNT/L2xCoXUln/Xrl0uiMuTJ48nX758nq5du7ob3GA7HruwnurvhL0u1I4ntUF3MB0PEChp/V1CeErNvSIig035atdMi3eKFi3q7m8IuDNXlP0T6NZ2AAAAAADCUcT26QYAAAAAIKMRdAMAAAAAkEEIugEAAAAAyCAE3QAAAAAAZBCCbgAAAAAAMghBNwAAAAAAGYSgGwAAAACADELQDQAAAABABiHoBgAAAELYHXfcoQ4dOgS6GABOgaAbCJOLbVRUlFuyZcumCy64QE8//bSOHz8e6KIBAIBz4Lu+n2oZOHCgXnvtNY0ePZrzDASpLIEuAID00bp1a40aNUqxsbGaMGGC7r//fmXNmlV9+vQJ6CmOi4tzFQEAACDttm7d6v//Z599pgEDBmjVqlX+bXny5HELgOBFSzcQJrJnz64SJUqofPny6tGjh1q0aKFvvvlGe/bs0e23366CBQsqV65catOmjVavXu1e4/F4VLRoUX3xxRf+96ldu7ZKlizpX589e7Z778OHD7v1vXv36q677nKvy5cvn5o1a6YlS5b497cad3uPd999VxUrVlSOHDky9TwAABBO7NruW/Lnz+9atxNvs4A7eXp5kyZN9MADD6hXr17u+l+8eHG98847OnTokLp27aq8efO6rLiJEycm+axly5a5+wR7T3vNbbfdpn/++ScARw2EF4JuIEzlzJnTtTLbhXjhwoUuAJ8zZ44LtK+++modO3bMXbivuOIKzZgxw73GAvSVK1fqyJEj+uOPP9y2mTNnqn79+i5gNzfccIN27NjhLtSLFi3SxRdfrObNm2v37t3+z16zZo2+/PJLjRs3TosXLw7QGQAAIHJ98MEHKlKkiObPn+8CcKuQt2v4pZdeql9//VUtW7Z0QXXiSnWrSK9Tp467b5g0aZK2b9+uG2+8MdCHAoQ8gm4gzFhQ/eOPP2ry5MkqV66cC7at1fnyyy9XrVq19PHHH2vz5s366quv/LXhvqB71qxZ7mKbeJs9Xnnllf5Wb7t4jx07VvXq1VOlSpX00ksvqUCBAklayy3Y//DDD9171axZMyDnAQCASGbX/H79+rlrtXU1s8wzC8K7d+/utlma+q5du/T777+7/d944w133R48eLCqVq3q/v/+++9r+vTp+vPPPwN9OEBII+gGwsR3333n0sHsomqpYTfddJNr5c6SJYsaNmzo369w4cKqUqWKa9E2FlCvWLFCO3fudK3aFnD7gm5rDf/ll1/curE08oMHD7r38PUhs2XdunVau3at/zMsxd3SzwEAQGAkrvSOiYlx1+4aNWr4t1n6uLHsNd813gLsxNd3C75N4ms8gLRjIDUgTDRt2lQjRoxwg5aVKlXKBdvWyn0mdgEuVKiQC7htee6551wfsaFDh2rBggUu8LZUNGMBt/X39rWCJ2at3T65c+dO56MDAABpYYOpJmZdyhJvs3WTkJDgv8a3a9fOXf+TSzzWC4C0I+gGwoQFujYoSmLVqlVz04bNmzfPHzhbKpmNelq9enX/RddSz7/++mstX75cjRs3dv23bRT0t956y6WR+4Jo67+9bds2F9BXqFAhAEcJAAAygl3jbTwWu77bdR5A+iG9HAhj1merffv2rv+W9ce21LFbb71VpUuXdtt9LH38k08+caOOWzpZdHS0G2DN+n/7+nMbGxG9UaNGboTUH374QX///bdLP3/yySfdoCsAACA02VSjNijqLbfc4jLdLKXcxoex0c7j4+MDXTwgpBF0A2HO5u6uW7eurrnmGhcw20BrNo934hQzC6ztgurru23s/8m3Wau4vdYCcrsIV65cWTfffLPWr1/v7xsGAABCj3VN+/nnn92130Y2t+5nNuWYdR+zyngAZy/KY3fgAAAAAAAg3VFtBQAAAABABiHoBgAAAAAggxB0AwAAAACQQQi6AQAAAADIIATdAAAAAABkEIJuAAAAAAAyCEE3AAAAAAAZhKAbAAAAAIAMQtANAAAAAEAGIegGAAAAACCDEHQDAAAAAJBBCLoBAAAAAFDG+H/6uPx+5zqtFAAAAABJRU5ErkJggg==" - }, - "metadata": {}, - "output_type": "display_data", - "jetTransient": { - "display_id": null - } - } - ], - "execution_count": 326 + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -697,25 +415,8 @@ "print(\"x segments:\\n\", x_seg.to_pandas())\n", "print(\"y segments:\\n\", y_seg.to_pandas())" ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "x segments:\n", - " _breakpoint 0 1\n", - "_segment \n", - "0 0.0 0.0\n", - "1 50.0 80.0\n", - "y segments:\n", - " _breakpoint 0 1\n", - "_segment \n", - "0 0.0 0.0\n", - "1 125.0 200.0\n" - ] - } - ], - "execution_count": 327 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -750,7 +451,7 @@ "m3.add_objective((cost + 10 * backup).sum())" ], "outputs": [], - "execution_count": 328 + "execution_count": null }, { "cell_type": "code", @@ -770,68 +471,8 @@ "source": [ "m3.solve(reformulate_sos=\"auto\")" ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set parameter Username\n", - "Academic license - for non-commercial use only - expires 2026-12-18\n", - "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-1yu_ivcs.lp\n", - "Reading time = 0.00 seconds\n", - "obj: 18 rows, 27 columns, 48 nonzeros\n", - "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n", - "\n", - "CPU model: Apple M3\n", - "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n", - "\n", - "Optimize a model with 18 rows, 27 columns and 48 nonzeros (Min)\n", - "Model fingerprint: 0x8ec14c73\n", - "Model has 6 linear objective coefficients\n", - "Model has 6 SOS constraints\n", - "Variable types: 21 continuous, 6 integer (6 binary)\n", - "Coefficient statistics:\n", - " Matrix range [1e+00, 2e+02]\n", - " Objective range [1e+00, 1e+01]\n", - " Bounds range [1e+00, 8e+01]\n", - " RHS range [1e+00, 9e+01]\n", - "\n", - "Presolve removed 15 rows and 22 columns\n", - "Presolve time: 0.00s\n", - "Presolved: 3 rows, 5 columns, 8 nonzeros\n", - "Variable types: 4 continuous, 1 integer (1 binary)\n", - "Found heuristic solution: objective 575.0000000\n", - "\n", - "Root relaxation: cutoff, 0 iterations, 0.00 seconds (0.00 work units)\n", - "\n", - "Explored 1 nodes (0 simplex iterations) in 0.01 seconds (0.00 work units)\n", - "Thread count was 8 (of 8 available processors)\n", - "\n", - "Solution count 1: 575 \n", - "\n", - "Optimal solution found (tolerance 1.00e-04)\n", - "Best objective 5.750000000000e+02, best bound 5.750000000000e+02, gap 0.0000%\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Dual values of MILP couldn't be parsed\n" - ] - }, - { - "data": { - "text/plain": [ - "('ok', 'optimal')" - ] - }, - "execution_count": 329, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 329 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -851,76 +492,8 @@ "source": [ "m3.solution[[\"power\", \"cost\", \"backup\"]].to_pandas()" ], - "outputs": [ - { - "data": { - "text/plain": [ - " power cost backup\n", - "time \n", - "1 0.0 0.0 10.0\n", - "2 70.0 175.0 0.0\n", - "3 80.0 200.0 10.0" - ], - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
powercostbackup
time
10.00.010.0
270.0175.00.0
380.0200.010.0
\n", - "
" - ] - }, - "execution_count": 330, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 330 + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -1343,7 +916,7 @@ "m4.add_objective(-fuel.sum())" ], "outputs": [], - "execution_count": 331 + "execution_count": null }, { "cell_type": "code", @@ -1363,52 +936,8 @@ "source": [ "m4.solve(reformulate_sos=\"auto\")" ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set parameter Username\n", - "Academic license - for non-commercial use only - expires 2026-12-18\n", - "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-gtsjz8uh.lp\n", - "Reading time = 0.00 seconds\n", - "obj: 12 rows, 6 columns, 21 nonzeros\n", - "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n", - "\n", - "CPU model: Apple M3\n", - "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n", - "\n", - "Optimize a model with 12 rows, 6 columns and 21 nonzeros (Min)\n", - "Model fingerprint: 0x0a213b23\n", - "Model has 3 linear objective coefficients\n", - "Coefficient statistics:\n", - " Matrix range [8e-01, 1e+00]\n", - " Objective range [1e+00, 1e+00]\n", - " Bounds range [1e+02, 1e+02]\n", - " RHS range [1e+01, 1e+02]\n", - "\n", - "Presolve removed 12 rows and 6 columns\n", - "Presolve time: 0.00s\n", - "Presolve: All rows and columns removed\n", - "Iteration Objective Primal Inf. Dual Inf. Time\n", - " 0 -2.3250000e+02 0.000000e+00 0.000000e+00 0s\n", - "\n", - "Solved in 0 iterations and 0.00 seconds (0.00 work units)\n", - "Optimal objective -2.325000000e+02\n" - ] - }, - { - "data": { - "text/plain": [ - "('ok', 'optimal')" - ] - }, - "execution_count": 332, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 332 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -1428,71 +957,8 @@ "source": [ "m4.solution[[\"power\", \"fuel\"]].to_pandas()" ], - "outputs": [ - { - "data": { - "text/plain": [ - " power fuel\n", - "time \n", - "1 30.0 37.5\n", - "2 80.0 90.0\n", - "3 100.0 105.0" - ], - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
powerfuel
time
130.037.5
280.090.0
3100.0105.0
\n", - "
" - ] - }, - "execution_count": 333, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 333 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -1513,22 +979,8 @@ "bp4 = linopy.breakpoints({\"power\": x_pts4.values, \"fuel\": y_pts4.values}, dim=\"var\")\n", "plot_pwl_results(m4, bp4, demand4, color=\"C4\")" ], - "outputs": [ - { - "data": { - "text/plain": [ - "
" - ], - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAFUCAYAAAA57l+/AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/TGe4hAAAACXBIWXMAAA9hAAAPYQGoP6dpAABhhklEQVR4nO3dCZzN1f/H8fcsZux7DFmTQgkhkchSiKK0/rVJlEjy+0UUUiH6/bRISGX5/UplScsvSrYWY0+btZJk39cYZu7/8Tm3O90Zgxlm5s699/V8PL5mvt/7vXfO/c645/s553POifB4PB4BAAAAAIBMF5n5LwkAAAAAAAi6AQAAAADIQvR0AwAAAACQRQi6AQAAAADIIgTdAAAAAABkEYJuAAAAAACyCEE3AAAAAABZhKAbAAAAAIAsQtANAAAAAEAWIegGAAAAAuzpp59WRESEgp29h+7duwe6GECOQtANZJMJEya4isi35c6dWxdddJGrmLZv3+7OWbJkiXvsxRdfPOn5bdu2dY+NHz/+pMcaNWqk888/P3n/mmuu0aWXXprF7wgAAGSk3i9durRatGihV155RQcPHsyRF+/TTz91DQAAMg9BN5DNnnnmGf3nP//Rq6++qgYNGmj06NGqX7++jhw5ossvv1x58+bV119/fdLzFi5cqOjoaH3zzTcpjickJGjp0qW66qqrsvFdAACAjNT7Vt8/8sgj7ljPnj1VvXp1ff/998nnPfXUU/rzzz9zRNA9aNCgQBcDCCnRgS4AEG5atWqlOnXquO8feOABFStWTCNGjNCHH36oO++8U/Xq1TspsF67dq127dql//u//zspIF++fLmOHj2qhg0bKhhY44I1LAAAEG71vunbt6/mzp2rNm3a6MYbb9Tq1auVJ08e17BuG4DQQ083EGBNmzZ1Xzds2OC+WvBs6eY///xz8jkWhBcsWFBdunRJDsD9H/M9LzPs27dPjz32mCpUqKDY2FiVKVNG99xzT/LP9KXL/fbbbymeN3/+fHfcvqZOc7eGAUuBt2C7X79+7kbjggsuSPPnW6+//82J+e9//6vatWu7m5KiRYvqjjvu0KZNmzLl/QIAEIi6v3///tq4caOr4041pnv27Nmufi9cuLDy58+viy++2NWjqeve9957zx2Pi4tTvnz5XDCfup786quvdOutt6pcuXKufi9btqyr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9bRkyRKFqqFDh6pu3boqUKCASpQo4dZSXbt2bYpzjh49qm7duqlYsWLKnz+/2rdvr+3btytUPf/884qIiFDPnj3D7hps3rxZd911l3ufefLkUfXq1bVs2bLkx226B5uFuFSpUu7x5s2ba/369QoViYmJ6t+/vypWrOjeX6VKlfTss8+69x3K1+DLL7/UDTfcoNKlS7u//RkzZqR4PD3vec+ePerQoYNb/9fWVu7UqZMOHTqkULgGx48fV58+fdz/h3z58rlz7rnnHm3ZsiWkrgFC25n+nyM8pOe+D+Fh9OjRuuyyy1ydZVv9+vU1c+bMQBcrrIR10P3ee++5dUVt5vIVK1aoRo0aatGihXbs2KFQtGDBAhdMLlq0SLNnz3Y3l9ddd51bR9Xnscce08cff6wpU6a48+1G8+abb1YoWrp0qcaOHes+hPyFwzXYu3evrrrqKuXKlct96K5atUr//ve/VaRIkeRzhg8frldeeUVjxozR4sWLXQBi/z+sUSIUDBs2zFVCr776qlavXu327T2PHDkypK+B/X+3zzprcExLet6zBZs//fST+xz55JNP3A1+ly5dFArX4MiRI64+sAYZ+zp9+nR3k3rjjTemOC/YrwFC25n+nyM8pOe+D+GhTJkyrqNp+fLlroOladOmatu2ravHkE08YeyKK67wdOvWLXk/MTHRU7p0ac/QoUM94WDHjh3WpedZsGCB29+3b58nV65cnilTpiSfs3r1andOfHy8J5QcPHjQU7lyZc/s2bM9jRs39jz66KNhdQ369Onjadiw4SkfT0pK8sTFxXleeOGF5GN2bWJjYz2TJ0/2hILWrVt77r///hTHbr75Zk+HDh3C5hrY3/UHH3yQvJ+e97xq1Sr3vKVLlyafM3PmTE9ERIRn8+bNnmC/BmlZsmSJO2/jxo0heQ0Q2tLzNw5PWN73IbwVKVLE88YbbwS6GGEjbHu6ExISXGuPpU76REZGuv34+HiFg/3797uvRYsWdV/telgrqP81qVKlisqVKxdy18Raflu3bp3ivYbTNfjoo49Up04d3XrrrS7lrFatWho3blzy4xs2bNC2bdtSXIdChQq5IRihch0aNGigOXPmaN26dW7/u+++09dff61WrVqFzTVILT3v2b5aOrX9/fjY+fb5aT3jofpZaSm69r7D9RoACL37PoQnG1737rvvuowHSzNH9ohWmNq1a5f7oytZsmSK47a/Zs0ahbqkpCQ3jtlSjC+99FJ3zG62Y2Jikm8s/a+JPRYq7IPG0kYtvTy1cLkGv/76q0uttuEV/fr1c9eiR48e7r3fe++9ye81rf8foXIdnnjiCR04cMA1qkRFRbnPg8GDB7u0YRMO1yC19Lxn+2oNNf6io6PdTVwoXhdLq7cx3nfeeacbBxeO1wBAaN73Ibz88MMPLsi2es3mLPrggw9UrVq1QBcrbIRt0B3urKf3xx9/dD174WTTpk169NFH3dgmmzwvnCtf66UbMmSI27eebvt7sHG8FnSHg/fff19vv/223nnnHV1yySVauXKluyGxiYfC5Rrg9Czr5bbbbnOTy1kjFQAEq3C978PfLr74YnevYxkPU6dOdfc6Nu6fwDt7hG16efHixV3vVupZqW0/Li5Ooax79+5u4p958+a5iRV87H1b2v2+fftC9ppY+rhNlHf55Ze7ninb7APHJo6y761HL9SvgbGZqVN/yFatWlW///67+973XkP5/8fjjz/uervvuOMON1P13Xff7SbRs9lew+UapJae92xfU082eeLECTebdyhdF1/AvXHjRtdI5+vlDqdrACC07/sQXiyb8cILL1Tt2rXdvY5Ntvjyyy8HulhhIzKc//Dsj87GdPr3/tl+qI5vsN4a++C1dJK5c+e6pZL82fWw2az9r4nN2muBWKhck2bNmrn0Gmvp823W42spxb7vQ/0aGEsvS71siI1tLl++vPve/jYsePC/DpaKbeNVQ+U62CzVNgbXnzXE2edAuFyD1NLznu2rNUpZA5aPfZ7YdbOx36EUcNtSaV988YVbVs9fOFwDAKF/34fwZnXWsWPHAl2M8OEJY++++66blXfChAluNtouXbp4Chcu7Nm2bZsnFHXt2tVTqFAhz/z58z1bt25N3o4cOZJ8zkMPPeQpV66cZ+7cuZ5ly5Z56tev77ZQ5j97ebhcA5uNOTo62jN48GDP+vXrPW+//bYnb968nv/+97/J5zz//PPu/8OHH37o+f777z1t27b1VKxY0fPnn396QsG9997rOf/88z2ffPKJZ8OGDZ7p06d7ihcv7undu3dIXwObuf/bb791m1UBI0aMcN/7ZuZOz3tu2bKlp1atWp7Fixd7vv76a7cSwJ133ukJhWuQkJDgufHGGz1lypTxrFy5MsVn5bFjx0LmGiC0nen/OcJDeu77EB6eeOIJN2u93e9Y3W77tuLG559/HuiihY2wDrrNyJEjXYAVExPjlhBbtGiRJ1RZxZvWNn78+ORz7Mb64YcfdssIWBB20003uQ/ocAq6w+UafPzxx55LL73UNTxVqVLF8/rrr6d43JaP6t+/v6dkyZLunGbNmnnWrl3rCRUHDhxwv3f7/587d27PBRdc4HnyySdTBFaheA3mzZuX5ueANUKk9z3v3r3bBZj58+f3FCxY0NOxY0d3kx8K18BuSE71WWnPC5VrgNB2pv/nCA/pue9DeLAlUsuXL+/infPOO8/V7QTc2SvC/gl0bzsAAAAAAKEobMd0AwAAAACQ1Qi6AQAAAADIIgTdAAAAAABkEYJuAAAAAACyCEE3AAAAAABZhKAbAAAAAIAsQtANAAAAAEAWIegGAAAAACCLEHQDAAAAQey+++5Tu3btAl0MAKdA0A2ESGUbERHhtpiYGF144YV65plndOLEiUAXDQAAnANf/X6q7emnn9bLL7+sCRMmcJ2BHCo60AUAkDlatmyp8ePH69ixY/r000/VrVs35cqVS3379g3oJU5ISHANAQAAIOO2bt2a/P17772nAQMGaO3atcnH8ufP7zYAORc93UCIiI2NVVxcnMqXL6+uXbuqefPm+uijj7R3717dc889KlKkiPLmzatWrVpp/fr17jkej0fnnXeepk6dmvw6NWvWVKlSpZL3v/76a/faR44ccfv79u3TAw884J5XsGBBNW3aVN99913y+dbibq/xxhtvqGLFisqdO3e2XgcAAEKJ1e2+rVChQq532/+YBdyp08uvueYaPfLII+rZs6er/0uWLKlx48bp8OHD6tixowoUKOCy4mbOnJniZ/3444/uPsFe055z9913a9euXQF410BoIegGQlSePHlcL7NVxMuWLXMBeHx8vAu0r7/+eh0/ftxV3I0aNdL8+fPdcyxAX716tf7880+tWbPGHVuwYIHq1q3rAnZz6623aseOHa6iXr58uS6//HI1a9ZMe/bsSf7ZP//8s6ZNm6bp06dr5cqVAboCAACEr4kTJ6p48eJasmSJC8CtQd7q8AYNGmjFihW67rrrXFDt36huDem1atVy9w2zZs3S9u3bddtttwX6rQBBj6AbCDEWVH/xxRf67LPPVK5cORdsW6/z1VdfrRo1aujtt9/W5s2bNWPGjOTWcF/Q/eWXX7rK1v+YfW3cuHFyr7dV3lOmTFGdOnVUuXJl/etf/1LhwoVT9JZbsD9p0iT3WpdddllArgMAAOHM6vynnnrK1dU21MwyzywI79y5sztmaeq7d+/W999/785/9dVXXb09ZMgQValSxX3/1ltvad68eVq3bl2g3w4Q1Ai6gRDxySefuHQwq1QtNez22293vdzR0dGqV69e8nnFihXTxRdf7Hq0jQXUq1at0s6dO12vtgXcvqDbesMXLlzo9o2lkR86dMi9hm8MmW0bNmzQL7/8kvwzLMXd0s8BAEBg+Dd6R0VFubq7evXqyccsfdxY9pqvjrcA279+t+Db+NfxADKOidSAENGkSRONHj3aTVpWunRpF2xbL/eZWAVctGhRF3DbNnjwYDdGbNiwYVq6dKkLvC0VzVjAbeO9fb3g/qy32ydfvnyZ/O4AAEBG2GSq/mxImf8x2zdJSUnJdfwNN9zg6v/U/Od6AZBxBN1AiLBA1yZF8Ve1alW3bNjixYuTA2dLJbNZT6tVq5Zc6Vrq+YcffqiffvpJDRs2dOO3bRb0sWPHujRyXxBt47e3bdvmAvoKFSoE4F0CAICsYHW8zcdi9bvV8wAyD+nlQAizMVtt27Z147dsPLaljt111106//zz3XEfSx+fPHmym3Xc0skiIyPdBGs2/ts3ntvYjOj169d3M6R+/vnn+u2331z6+ZNPPukmXQEAAMHJlhq1SVHvvPNOl+lmKeU2P4zNdp6YmBjo4gFBjaAbCHG2dnft2rXVpk0bFzDbRGu2jrd/ipkF1lah+sZuG/s+9THrFbfnWkBulfBFF12kO+64Qxs3bkweGwYAAIKPDU375ptvXN1vM5vb8DNbcsyGj1ljPICzF+GxO3AAAAAAAJDpaLYCAAAAACCLEHQDAAAAAJBFCLoBAAAAAMgiBN0AAAAAAGQRgm4AAAAAALIIQTcAAAAAAFmEoBsAAAAAgCxC0A0AAAAAQBYh6AYAAAAAIIsQdAMAAAAAkEUIugEAAAAAyCIE3QAAAAAAKGv8P/JlaiIU4rmcAAAAAElFTkSuQmCC" - }, - "metadata": {}, - "output_type": "display_data", - "jetTransient": { - "display_id": null - } - } - ], - "execution_count": 334 + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -1562,16 +1014,8 @@ "y_pts5 = linopy.breakpoints(slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0)\n", "print(\"y breakpoints from slopes:\", y_pts5.values)" ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "y breakpoints from slopes: [ 0. 55. 130. 225.]\n" - ] - } - ], - "execution_count": 335 + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -2505,17 +1949,8 @@ "print(\"Power breakpoints:\", x_pts6.values)\n", "print(\"Fuel breakpoints: \", y_pts6.values)" ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Power breakpoints: [ 30. 60. 100.]\n", - "Fuel breakpoints: [ 40. 90. 170.]\n" - ] - } - ], - "execution_count": 336 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -2552,7 +1987,7 @@ "m6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())" ], "outputs": [], - "execution_count": 337 + "execution_count": null }, { "cell_type": "code", @@ -2565,74 +2000,8 @@ "source": [ "m6.solve(reformulate_sos=\"auto\")" ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set parameter Username\n", - "Academic license - for non-commercial use only - expires 2026-12-18\n", - "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-k90jz3qk.lp\n", - "Reading time = 0.00 seconds\n", - "obj: 27 rows, 24 columns, 66 nonzeros\n", - "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n", - "\n", - "CPU model: Apple M3\n", - "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n", - "\n", - "Optimize a model with 27 rows, 24 columns and 66 nonzeros (Min)\n", - "Model fingerprint: 0x4b0d5f70\n", - "Model has 9 linear objective coefficients\n", - "Variable types: 15 continuous, 9 integer (9 binary)\n", - "Coefficient statistics:\n", - " Matrix range [1e+00, 8e+01]\n", - " Objective range [1e+00, 5e+01]\n", - " Bounds range [1e+00, 1e+02]\n", - " RHS range [2e+01, 7e+01]\n", - "\n", - "Found heuristic solution: objective 675.0000000\n", - "Presolve removed 24 rows and 19 columns\n", - "Presolve time: 0.00s\n", - "Presolved: 3 rows, 5 columns, 10 nonzeros\n", - "Found heuristic solution: objective 485.0000000\n", - "Variable types: 3 continuous, 2 integer (2 binary)\n", - "\n", - "Root relaxation: objective 3.516667e+02, 3 iterations, 0.00 seconds (0.00 work units)\n", - "\n", - " Nodes | Current Node | Objective Bounds | Work\n", - " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", - "\n", - " 0 0 351.66667 0 1 485.00000 351.66667 27.5% - 0s\n", - "* 0 0 0 358.3333333 358.33333 0.00% - 0s\n", - "\n", - "Explored 1 nodes (5 simplex iterations) in 0.02 seconds (0.00 work units)\n", - "Thread count was 8 (of 8 available processors)\n", - "\n", - "Solution count 3: 358.333 485 675 \n", - "\n", - "Optimal solution found (tolerance 1.00e-04)\n", - "Best objective 3.583333333333e+02, best bound 3.583333333333e+02, gap 0.0000%\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Dual values of MILP couldn't be parsed\n" - ] - }, - { - "data": { - "text/plain": [ - "('ok', 'optimal')" - ] - }, - "execution_count": 338, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 338 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -2645,81 +2014,8 @@ "source": [ "m6.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()" ], - "outputs": [ - { - "data": { - "text/plain": [ - " commit power fuel backup\n", - "time \n", - "1 0.0 0.0 0.000000 15.0\n", - "2 1.0 70.0 110.000000 0.0\n", - "3 1.0 50.0 73.333333 0.0" - ], - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
commitpowerfuelbackup
time
10.00.00.00000015.0
21.070.0110.0000000.0
31.050.073.3333330.0
\n", - "
" - ] - }, - "execution_count": 339, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 339 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -2733,22 +2029,8 @@ "bp6 = linopy.breakpoints({\"power\": x_pts6.values, \"fuel\": y_pts6.values}, dim=\"var\")\n", "plot_pwl_results(m6, bp6, demand6, color=\"C2\")" ], - "outputs": [ - { - "data": { - "text/plain": [ - "
" - ], - "image/png": "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vHnzgH4PAAEUcyZxx1k1cyCNeuCBB9SyZctgnwYApFoE3eebx7fxR2nFV56vth1A06ZNc3Pip0yZoh07duiKK65QSlaJfeedd/TCCy8onIwbN04vv/xyoo+3Je2S2iHy4IMPatmyZW4JPQCpiE2jWvyRNOmJCxwYIeUs6lk+DGHPglNrJ+xh61cXKlRIN954oz7++GOdOZPIDhwAQJoTsCXDUrUgLA2zYcMGt1xU7dopf+P24Ycfuu9bsmTJS3qfkydPKmPGjEotLKsg0Ox63HPPPXr33XdVty7zPYFUwf72T+oirf/es12gkrRnzdkn/WuaRHi+NH2NImrwadq0qUaMGOGqsO/atct1qj/xxBP66quvNGnSJKVPz60XAIQbRroTWhombuEc79Iw9nwAesa7dOmiLVu2uN7xUqVKuf329e233451rKWKW8q4l6Vg//e//1WBAgWUM2dONWzYUL/++muSvr9Vk48vBfr06dPq3LmzcuXKpfz587sUdP8ienZ+NlJ8//33u+/tTU//6aefXIBpy4gUL15cjz/+uI4cOeJ73f/93/+pRo0absm4qKgoF5R616mOj61j3axZM11//fXu5/WOONt5W2dB5syZXWbAnDlzYr3Otq+99lpXMd86NJ577jn3MyWUXm4/T79+/dzotJ2bLYH3/vvv+563Svvmqquuct/fXu9Nh7fvky1bNuXOndud5+bNm32vs2trN1rHjh1Lwm8FQFBYdtPQWp6AO31mqUl/6dF5nmXBchaOfax1xrJcGOKwNsfatqJFi7risc8//7wmTpyoqVOn+lZ5uVDb7Z0aZiPk1hZlz55djz32mAvkBwwY4N7flph89dVXY33vt956S1WqVHHtkbW/9hpb6tXLvr+1U9OnT1elSpXc+1ongWXYedn3sBVl7DhbevXZZ5+9YAFdAMD5hUfQbY3FySMXfhw/KE199jxLw1geeHfPcYl5v0Q2Upba3bdvXxUrVsw1fIsXL070j3b77be7gNUa86VLl7oGvlGjRtq3b1+iXm/HrVq1ygXBcY0aNcr1yC9atMidozXmNiru780331S1atX0yy+/uKDcRuytAW/Tpo1+++03ffHFFy4It+DdfzkYC9btBmPChAkuiLaOh/jYjYml5lla3owZM9xNgNczzzyjp556yn3vWrVqueB279697rlt27bp5ptv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" - }, - "metadata": {}, - "output_type": "display_data", - "jetTransient": { - "display_id": null - } - } - ], - "execution_count": 340 + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -3469,21 +2751,8 @@ "print(\"CHP breakpoints:\")\n", "print(bp_chp.to_pandas())" ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "CHP breakpoints:\n", - "_breakpoint 0 1 2 3\n", - "var \n", - "power 0.0 30.0 60.0 100.0\n", - "fuel 0.0 40.0 85.0 160.0\n", - "heat 0.0 25.0 55.0 95.0\n" - ] - } - ], - "execution_count": 341 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -3516,7 +2785,7 @@ "m7.add_objective(fuel.sum())" ], "outputs": [], - "execution_count": 342 + "execution_count": null }, { "cell_type": "code", @@ -3529,64 +2798,8 @@ "source": [ "m7.solve(reformulate_sos=\"auto\")" ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set parameter Username\n", - "Academic license - for non-commercial use only - expires 2026-12-18\n", - "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-5535qbzh.lp\n", - "Reading time = 0.00 seconds\n", - "obj: 15 rows, 21 columns, 51 nonzeros\n", - "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n", - "\n", - "CPU model: Apple M3\n", - "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n", - "\n", - "Optimize a model with 15 rows, 21 columns and 51 nonzeros (Min)\n", - "Model fingerprint: 0x508c4706\n", - "Model has 3 linear objective coefficients\n", - "Model has 3 SOS constraints\n", - "Variable types: 21 continuous, 0 integer (0 binary)\n", - "Coefficient statistics:\n", - " Matrix range [1e+00, 2e+02]\n", - " Objective range [1e+00, 1e+00]\n", - " Bounds range [1e+00, 1e+02]\n", - " RHS range [1e+00, 9e+01]\n", - "\n", - "Presolve removed 15 rows and 21 columns\n", - "Presolve time: 0.00s\n", - "Presolve: All rows and columns removed\n", - "\n", - "Explored 0 nodes (0 simplex iterations) in 0.01 seconds (0.00 work units)\n", - "Thread count was 1 (of 8 available processors)\n", - "\n", - "Solution count 2: 252.917 252.917 \n", - "\n", - "Optimal solution found (tolerance 1.00e-04)\n", - "Best objective 2.529166651870e+02, best bound 2.529166651870e+02, gap 0.0000%\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Dual values of MILP couldn't be parsed\n" - ] - }, - { - "data": { - "text/plain": [ - "('ok', 'optimal')" - ] - }, - "execution_count": 343, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 343 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -3599,76 +2812,8 @@ "source": [ "m7.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas().round(2)" ], - "outputs": [ - { - "data": { - "text/plain": [ - " power fuel heat\n", - "time \n", - "1 20.0 26.67 16.67\n", - "2 60.0 85.00 55.00\n", - "3 90.0 141.25 85.00" - ], - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
powerfuelheat
time
120.026.6716.67
260.085.0055.00
390.0141.2585.00
\n", - "
" - ] - }, - "execution_count": 344, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 344 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -3681,22 +2826,8 @@ "source": [ "plot_pwl_results(m7, bp_chp, power_dispatch, x_name=\"fuel\")" ], - "outputs": [ - { - "data": { - "text/plain": [ - "
" - ], - "image/png": "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xIkTVQjv2bOnWifN1OfOnYvTp0+jTp06+OGHH1TglrJf88svv8DX1xeXL19GqVKl+FEQvSSGbiIr0KhRI1WwaiRwSwGcGhloTe5+N2vWLNH66OhoVSgTERGR+apUqZL+33Z2dsibNy8qVqyoXyfNx0VQUJD6eurUKezcuTPZ/uFXr15l6CbKAIZuIisgIVv6bhmytbWFTqdLtC4mJkb/76dPn6qvGzduhLe393N9xImIiMh8OTg4JFqWvuCG62RZG79FK/dbt26NSZMmPbevggULmvx4ibIzhm4iKyWjl8uAKYZOnjypL5DLlSunwvWtW7fYlJyIiCibq1atGv744w8ULVoU9vaMCETGxIHUiKyUDJYiA6XIQGlXrlzBmDFjEoVwGdn0f//7nxo8TQZjkaZlMvDKrFmz1DIRERFlH3379sXjx4/RpUsXHDlyRJX7W7ZsUaOdx8XFZfXhEVk0hm4iK9W8eXOMGjUKQ4cORc2aNREWFoYPPvgg0Tbjxo1T28go5mXLllXzfUtzc5lCjIiIiLKPQoUKYd++fSpgy8jm0v9bphzLlSuX6pJGRC/PRpe0UycRERERERERGQVvWxERERERERGZCEM3ERERERERkYkwdBMRERERERGZCEM3ERERERERkYkwdBMRERERERGZCEM3ERERERERkYkwdBMRERERERGZCEM3ERERERERkYkwdBMRERERERGZCEM3ERERERERkYkwdBMRERERERGZCEM3EREREREREUzj/wFkMYA2K4304wAAAABJRU5ErkJggg==" - }, - "metadata": {}, - "output_type": "display_data", - "jetTransient": { - "display_id": null - } - } - ], - "execution_count": 345 + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -3724,25 +2855,8 @@ "print(\"Power breakpoints:\\n\", x_gen.to_pandas())\n", "print(\"Fuel breakpoints:\\n\", y_gen.to_pandas())" ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Power breakpoints:\n", - " _breakpoint 0 1 2 3\n", - "gen \n", - "gas 0.0 30.0 60.0 100.0\n", - "coal 0.0 50.0 100.0 150.0\n", - "Fuel breakpoints:\n", - " _breakpoint 0 1 2 3\n", - "gen \n", - "gas 0.0 40.0 90.0 180.0\n", - "coal 0.0 55.0 130.0 225.0\n" - ] - } - ], - "execution_count": 346 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -3770,7 +2884,7 @@ "m8.add_objective(fuel.sum())" ], "outputs": [], - "execution_count": 347 + "execution_count": null }, { "cell_type": "code", @@ -3783,73 +2897,8 @@ "source": [ "m8.solve(reformulate_sos=\"auto\")" ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Set parameter Username\n", - "Academic license - for non-commercial use only - expires 2026-12-18\n", - "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-xk807eiy.lp\n", - "Reading time = 0.00 seconds\n", - "obj: 57 rows, 48 columns, 138 nonzeros\n", - "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n", - "\n", - "CPU model: Apple M3\n", - "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n", - "\n", - "Optimize a model with 57 rows, 48 columns and 138 nonzeros (Min)\n", - "Model fingerprint: 0x9060ba6d\n", - "Model has 6 linear objective coefficients\n", - "Variable types: 30 continuous, 18 integer (18 binary)\n", - "Coefficient statistics:\n", - " Matrix range [1e+00, 1e+02]\n", - " Objective range [1e+00, 1e+00]\n", - " Bounds range [1e+00, 2e+02]\n", - " RHS range [6e+01, 1e+02]\n", - "\n", - "Found heuristic solution: objective 357.5000000\n", - "Presolve removed 50 rows and 38 columns\n", - "Presolve time: 0.00s\n", - "Presolved: 7 rows, 10 columns, 23 nonzeros\n", - "Found heuristic solution: objective 340.0000000\n", - "Variable types: 6 continuous, 4 integer (4 binary)\n", - "\n", - "Root relaxation: objective 3.183333e+02, 1 iterations, 0.00 seconds (0.00 work units)\n", - "\n", - " Nodes | Current Node | Objective Bounds | Work\n", - " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n", - "\n", - "* 0 0 0 318.3333333 318.33333 0.00% - 0s\n", - "\n", - "Explored 1 nodes (1 simplex iterations) in 0.02 seconds (0.00 work units)\n", - "Thread count was 8 (of 8 available processors)\n", - "\n", - "Solution count 3: 318.333 340 357.5 \n", - "\n", - "Optimal solution found (tolerance 1.00e-04)\n", - "Best objective 3.183333333333e+02, best bound 3.183333333333e+02, gap 0.0000%\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "Dual values of MILP couldn't be parsed\n" - ] - }, - { - "data": { - "text/plain": [ - "('ok', 'optimal')" - ] - }, - "execution_count": 348, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 348 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -3862,93 +2911,8 @@ "source": [ "m8.solution[[\"power\", \"fuel\"]].to_dataframe().round(2)" ], - "outputs": [ - { - "data": { - "text/plain": [ - " power fuel\n", - "gen time \n", - "gas 1 30.0 40.00\n", - " 2 30.0 40.00\n", - " 3 10.0 13.33\n", - "coal 1 50.0 55.00\n", - " 2 90.0 115.00\n", - " 3 50.0 55.00" - ], - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
powerfuel
gentime
gas130.040.00
230.040.00
310.013.33
coal150.055.00
290.0115.00
350.055.00
\n", - "
" - ] - }, - "execution_count": 349, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 349 + "outputs": [], + "execution_count": null }, { "cell_type": "code", @@ -3974,7 +2938,7 @@ } } ], - "execution_count": 350 + "execution_count": null } ], "metadata": { From 27f1915c90c6ec43a18013401ff9c4acf6627136 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:42:49 +0200 Subject: [PATCH 20/30] docs: update release notes for piecewise API refactor Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/release_notes.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 54c98f43..192a9a47 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -11,11 +11,11 @@ Upcoming Version - Comparison operators (``==``, ``<=``, ``>=``) fill missing RHS coords with NaN (no constraint created) - Fixes crash on ``subset + var`` / ``subset + expr`` reverse addition - Fixes superset DataArrays expanding result coords beyond the variable's coordinate space -* Add ``add_piecewise_constraints()`` with SOS2, incremental, LP, and disjunctive formulations (``linopy.piecewise(x, x_pts, y_pts) == y``). -* Add ``linopy.piecewise()`` to create piecewise linear function descriptors (`PiecewiseExpression`) from separate x/y breakpoint arrays. +* Refactor ``add_piecewise_constraints()`` to a tuple-based API: ``m.add_piecewise_constraints((power, x_pts), (fuel, y_pts))``. Supports N-variable linking (e.g. CHP with fuel/power/heat), per-entity breakpoints, ``breakpoints()``, ``segments()``, and slopes mode. Removes ``piecewise()`` function and descriptor classes. +* Add ``tangent_lines()`` utility for piecewise inequality bounds — returns a ``LinearExpression`` with one tangent line per segment, no auxiliary variables created. Use with regular ``add_constraints``. * Add ``linopy.breakpoints()`` factory for convenient breakpoint construction from lists, Series, DataFrames, DataArrays, or dicts. Supports slopes mode. * Add ``linopy.segments()`` factory for disjunctive (disconnected) breakpoints. -* Add ``active`` parameter to ``piecewise()`` for gating piecewise linear functions with a binary variable (e.g. unit commitment). Supported for incremental, SOS2, and disjunctive methods. +* Add ``active`` parameter to ``add_piecewise_constraints()`` for gating piecewise linear functions with a binary variable (e.g. unit commitment). Supported for incremental, SOS2, and disjunctive methods. * Add the `sphinx-copybutton` to the documentation * Add SOS1 and SOS2 reformulations for solvers not supporting them. * Add semi-continous variables for solvers that support them From f08d120a782bb8f78a7f2f9d7854748994e28996 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:45:59 +0200 Subject: [PATCH 21/30] docs: frame piecewise as new feature in release notes, not refactor The descriptor API was never released, so for users this is all new. Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/release_notes.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 192a9a47..d1c7efea 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -11,11 +11,11 @@ Upcoming Version - Comparison operators (``==``, ``<=``, ``>=``) fill missing RHS coords with NaN (no constraint created) - Fixes crash on ``subset + var`` / ``subset + expr`` reverse addition - Fixes superset DataArrays expanding result coords beyond the variable's coordinate space -* Refactor ``add_piecewise_constraints()`` to a tuple-based API: ``m.add_piecewise_constraints((power, x_pts), (fuel, y_pts))``. Supports N-variable linking (e.g. CHP with fuel/power/heat), per-entity breakpoints, ``breakpoints()``, ``segments()``, and slopes mode. Removes ``piecewise()`` function and descriptor classes. -* Add ``tangent_lines()`` utility for piecewise inequality bounds — returns a ``LinearExpression`` with one tangent line per segment, no auxiliary variables created. Use with regular ``add_constraints``. +* Add ``add_piecewise_constraints()`` for piecewise linear equality constraints with SOS2, incremental, and disjunctive formulations: ``m.add_piecewise_constraints((power, x_pts), (fuel, y_pts))``. Supports N-variable linking (e.g. CHP with fuel/power/heat), per-entity breakpoints, and unit commitment via the ``active`` parameter. +* Add ``tangent_lines()`` for piecewise linear inequality bounds — returns a ``LinearExpression`` with one tangent line per segment, no auxiliary variables. Use with regular ``add_constraints``. * Add ``linopy.breakpoints()`` factory for convenient breakpoint construction from lists, Series, DataFrames, DataArrays, or dicts. Supports slopes mode. * Add ``linopy.segments()`` factory for disjunctive (disconnected) breakpoints. -* Add ``active`` parameter to ``add_piecewise_constraints()`` for gating piecewise linear functions with a binary variable (e.g. unit commitment). Supported for incremental, SOS2, and disjunctive methods. +* Add ``slopes_to_points()`` utility for converting segment slopes to breakpoint y-coordinates. * Add the `sphinx-copybutton` to the documentation * Add SOS1 and SOS2 reformulations for solvers not supporting them. * Add semi-continous variables for solvers that support them From 2fc3da6b3a7bed45d8e92c19ea4d4abc05a4cebc Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:52:51 +0200 Subject: [PATCH 22/30] fix: resolve mypy type error in incremental bp0_term assignment Co-Authored-By: Claude Opus 4.6 (1M context) --- linopy/piecewise.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 198d5cef..1bad2eab 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -1105,10 +1105,9 @@ def _add_continuous_nvar( model.add_constraints(binary_hi <= delta_lo, name=inc_order_name) bp0 = stacked_bp.isel({dim: 0}) + bp0_term: DataArray | LinearExpression = bp0 if active is not None: bp0_term = bp0 * active - else: - bp0_term = bp0 weighted_sum = (delta_var * steps).sum(dim=seg_dim) + bp0_term link_con = model.add_constraints(target_expr == weighted_sum, name=link_name) From 7fa0c428d190c12be5a4d5843acc70f84065ca5f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:57:26 +0200 Subject: [PATCH 23/30] docs: restructure piecewise documentation for readability Reorder: Quick Start -> API -> When to Use What -> Breakpoint Construction -> Formulation Methods -> Advanced Features. Add per-entity, slopes, and N-variable examples. Deduplicate code samples. Fold generated-variables tables into compact lists. Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/piecewise-linear-constraints.rst | 475 +++++++++++++-------------- test/test_piecewise_constraints.py | 16 + 2 files changed, 248 insertions(+), 243 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index be669a26..d4f3bfa7 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -7,68 +7,120 @@ Piecewise linear (PWL) constraints approximate nonlinear functions as connected linear segments, allowing you to model cost curves, efficiency curves, or production functions within a linear programming framework. -linopy offers two tools: - -- :py:meth:`~linopy.model.Model.add_piecewise_constraints` --- - exact equality on the piecewise curve (creates auxiliary variables). -- :func:`~linopy.piecewise.tangent_lines` --- - one-sided bounds via tangent lines (pure LP, no auxiliary variables). - .. contents:: :local: :depth: 2 -Equality vs Inequality ----------------------- +Quick Start +----------- -``add_piecewise_constraints`` --- exact equality on the curve -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. code-block:: python -Use this when variables must lie **exactly on** the piecewise curve -(:math:`y = f(x)`). It creates auxiliary variables (lambda weights or -delta fractions) and combinatorial constraints (SOS2 or binary indicators) -to enforce that the operating point is interpolated between adjacent -breakpoints. + import linopy -.. code-block:: python + m = linopy.Model() + power = m.add_variables(name="power", lower=0, upper=100) + fuel = m.add_variables(name="fuel") + # Link power and fuel via a piecewise linear curve m.add_piecewise_constraints( (power, [0, 30, 60, 100]), - (fuel, [0, 36, 84, 170]), + (fuel, [0, 36, 84, 170]), + ) + +Each ``(expression, breakpoints)`` tuple pairs a variable with its +breakpoint values. All tuples share interpolation weights, so at any +feasible point, every variable is interpolated between the *same* pair +of adjacent breakpoints. + + +API +--- + +``add_piecewise_constraints`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + m.add_piecewise_constraints( + (expr1, breakpoints1), + (expr2, breakpoints2), + ..., + method="auto", # "auto", "sos2", or "incremental" + active=None, # binary variable to gate the constraint + name=None, # base name for generated variables/constraints + skip_nan_check=False, ) -This is the only way to enforce exact piecewise equality. It requires -a MIP or SOS2-capable solver. +Creates auxiliary variables and constraints that enforce all expressions +to lie exactly on the piecewise curve. Requires a MIP or SOS2-capable +solver. + +``tangent_lines`` +~~~~~~~~~~~~~~~~~ + +.. code-block:: python -``tangent_lines`` --- one-sided bound, pure LP -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + t = linopy.tangent_lines(x, x_points, y_points) -Use this when a variable must be **bounded above or below** by the -piecewise curve (:math:`y \le f(x)` or :math:`y \ge f(x)`). It -computes one tangent line per segment and returns them as a regular -:class:`~linopy.expressions.LinearExpression` with a segment dimension. -**No auxiliary variables are created.** +Returns a :class:`~linopy.expressions.LinearExpression` with one tangent +line per segment. **No variables are created** --- the result is pure +linear algebra. Use it with regular ``add_constraints``: .. code-block:: python t = linopy.tangent_lines(power, x_pts, y_pts) - m.add_constraints(fuel <= t) # fuel bounded above by f(power) - m.add_constraints(fuel >= t) # fuel bounded below by f(power) + m.add_constraints(fuel <= t) # upper bound + m.add_constraints(fuel >= t) # lower bound + +``breakpoints`` and ``segments`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Factory functions that create DataArrays with the correct dimension names: + +.. code-block:: python + + linopy.breakpoints([0, 50, 100]) # list + linopy.breakpoints({"gen1": [0, 50], "gen2": [0, 80]}, dim="gen") # per-entity + linopy.breakpoints(slopes=[1.2, 1.4], x_points=[0, 30, 60], y0=0) # from slopes + linopy.segments([(0, 10), (50, 100)]) # disjunctive + linopy.segments({"gen1": [(0, 10)], "gen2": [(0, 80)]}, dim="gen") # per-entity -xarray broadcasting creates one linear constraint per segment per -coordinate entry. The result is solvable by **any LP solver** --- -no SOS2, no binaries. + +When to Use What +---------------- + +linopy provides two distinct tools for piecewise linear modelling. + +.. list-table:: + :header-rows: 1 + :widths: 30 35 35 + + * - + - ``add_piecewise_constraints`` + - ``tangent_lines`` + * - **Constraint type** + - Equality: :math:`y = f(x)` + - Inequality: :math:`y \le f(x)` or :math:`y \ge f(x)` + * - **Creates variables?** + - Yes (lambdas, deltas, binaries) + - No + * - **Solver requirement** + - MIP or SOS2-capable + - Any LP solver + * - **N-variable support** + - Yes + - No (2-variable only) .. warning:: ``tangent_lines`` does **not** work with equality. Writing - ``fuel == tangent_lines(...)`` would require fuel to simultaneously - satisfy every tangent line, which is infeasible except at breakpoints. + ``fuel == tangent_lines(...)`` creates one equality per segment, + which is overconstrained (infeasible except at breakpoints). Use ``add_piecewise_constraints`` for equality. -**When is the bound tight?** The tangent-line bound is exact (tight at -every point on the curve) when the function has the right convexity: +**When is the tangent-line bound tight?** - :math:`y \le f(x)` is tight when *f* is **concave** (slopes decrease) - :math:`y \ge f(x)` is tight when *f* is **convex** (slopes increase) @@ -76,232 +128,208 @@ every point on the curve) when the function has the right convexity: For other combinations the bound is valid but loose (a relaxation). -Overview --------- +Breakpoint Construction +----------------------- -``add_piecewise_constraints`` takes ``(expression, breakpoints)`` tuples as -positional arguments. All tuples share the same interpolation weights, -coupling the expressions on the same curve segment. +From lists +~~~~~~~~~~ -**2 variables:** +The simplest form --- pass Python lists directly in the tuple: .. code-block:: python m.add_piecewise_constraints( (power, [0, 30, 60, 100]), - (fuel, [0, 36, 84, 170]), + (fuel, [0, 36, 84, 170]), ) -**N variables (e.g. CHP plant):** +With the ``breakpoints()`` factory +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Equivalent, but explicit about the DataArray construction: .. code-block:: python m.add_piecewise_constraints( - (power, [0, 30, 60, 100]), - (fuel, [0, 40, 85, 160]), - (heat, [0, 25, 55, 95]), + (power, linopy.breakpoints([0, 30, 60, 100])), + (fuel, linopy.breakpoints([0, 36, 84, 170])), ) +From slopes +~~~~~~~~~~~ -Mathematical Background ------------------------ +When you know marginal costs (slopes) rather than absolute values: -Core formulation -~~~~~~~~~~~~~~~~ +.. code-block:: python + + m.add_piecewise_constraints( + (power, [0, 50, 100, 150]), + (cost, linopy.breakpoints(slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0)), + ) + # cost breakpoints: [0, 55, 130, 225] -The piecewise linear formulation links *N* expressions -:math:`e_1, e_2, \ldots, e_N` through a shared set of breakpoints. +Per-entity breakpoints +~~~~~~~~~~~~~~~~~~~~~~ -Given :math:`n+1` breakpoints :math:`B_{j,0}, B_{j,1}, \ldots, B_{j,n}` for -each expression :math:`j`, the SOS2 formulation introduces interpolation -weights :math:`\lambda_i \in [0, 1]`: +Different generators can have different curves. Pass a dict to +``breakpoints()`` with entity names as keys: -.. math:: +.. code-block:: python - &\sum_{i=0}^{n} \lambda_i = 1 - \qquad \text{(convexity)} + m.add_piecewise_constraints( + (power, linopy.breakpoints({"gas": [0, 30, 60, 100], "coal": [0, 50, 100, 150]}, dim="gen")), + (fuel, linopy.breakpoints({"gas": [0, 40, 90, 180], "coal": [0, 55, 130, 225]}, dim="gen")), + ) - &e_j = \sum_{i=0}^{n} \lambda_i \, B_{j,i} - \qquad \text{for each expression } j - \qquad \text{(linking)} +Ragged lengths are NaN-padded automatically. Breakpoints are +auto-broadcast over remaining dimensions (e.g. ``time``). - &\text{SOS2}(\lambda_0, \lambda_1, \ldots, \lambda_n) - \qquad \text{(adjacency)} +Disjunctive segments +~~~~~~~~~~~~~~~~~~~~~ -The SOS2 constraint ensures at most two *adjacent* :math:`\lambda_i` are -non-zero, so every expression is interpolated within the same segment. All -expressions share the same :math:`\lambda` weights, which is what couples them. +For disconnected operating regions (e.g. forbidden zones), use +``segments()``: +.. code-block:: python -Tangent lines (inequality) -~~~~~~~~~~~~~~~~~~~~~~~~~~ + m.add_piecewise_constraints( + (power, linopy.segments([(0, 0), (50, 80)])), + (cost, linopy.segments([(0, 0), (125, 200)])), + ) -:func:`~linopy.piecewise.tangent_lines` computes the tangent line for -each segment of the piecewise function: +The disjunctive formulation is selected automatically when breakpoints +have a segment dimension. -.. math:: +N-variable linking +~~~~~~~~~~~~~~~~~~ + +Link any number of variables through shared breakpoints. All variables +are symmetric --- there is no distinguished "x" or "y": - \text{tangent}_k(x) = m_k \cdot x + c_k \quad \text{for each segment } k +.. code-block:: python -where :math:`m_k = (y_{k+1} - y_k) / (x_{k+1} - x_k)` is the slope and -:math:`c_k = y_k - m_k \cdot x_k` is the intercept. The result is a -:class:`~linopy.expressions.LinearExpression` with a segment dimension --- -one linear expression per segment, no auxiliary variables. + m.add_piecewise_constraints( + (power, [0, 30, 60, 100]), + (fuel, [0, 40, 85, 160]), + (heat, [0, 25, 55, 95]), + ) Formulation Methods ------------------- -SOS2 (Convex Combination) -~~~~~~~~~~~~~~~~~~~~~~~~~ +Pass ``method="auto"`` (the default) and linopy picks the best +formulation automatically: -The default formulation, using Special Ordered Sets of type 2. Works for any -breakpoint ordering. +- **All breakpoints monotonic** --- incremental +- **Otherwise** --- SOS2 +- **Disjunctive** (segments) --- always SOS2 with binary selection -.. note:: +SOS2 (Convex Combination) +~~~~~~~~~~~~~~~~~~~~~~~~~~ - SOS2 is a combinatorial constraint handled via branch-and-bound. - Prefer ``method="incremental"`` or ``method="auto"`` when breakpoints are - monotonic. +Works for any breakpoint ordering. Introduces interpolation weights +:math:`\lambda_i` with an SOS2 adjacency constraint: -Incremental (Delta) Formulation -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. math:: -For **strictly monotonic** breakpoints :math:`b_0 < b_1 < \cdots < b_n`, the -incremental formulation uses fill-fraction variables: + &\sum_{i=0}^{n} \lambda_i = 1, \qquad + \text{SOS2}(\lambda_0, \ldots, \lambda_n) -.. math:: + &e_j = \sum_{i=0}^{n} \lambda_i \, B_{j,i} + \quad \text{for each expression } j - \delta_i \in [0, 1], \quad - \delta_{i+1} \le \delta_i, \quad - e_j = B_{j,0} + \sum_{i=1}^{n} \delta_i \, (B_{j,i} - B_{j,i-1}) +The SOS2 constraint ensures at most two adjacent :math:`\lambda_i` are +non-zero, so every expression is interpolated within the same segment. -Binary indicators enforce segment ordering. This avoids SOS2 constraints -entirely, using only standard MIP constructs. +.. note:: -**Limitation:** All breakpoint sequences must be strictly monotonic. + SOS2 is handled via branch-and-bound, similar to integer variables. + Prefer ``method="incremental"`` when breakpoints are monotonic. -Disjunctive (Disaggregated Convex Combination) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. code-block:: python -For **disconnected segments** (with gaps), binary indicators select exactly one -segment and SOS2 applies within it. No big-M constants are needed. + m.add_piecewise_constraints((power, xp), (fuel, yp), method="sos2") -.. math:: +Incremental (Delta) Formulation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - z_k \in \{0, 1\}, \quad \sum_{k} z_k = 1, \quad - \sum_{i} \lambda_{k,i} = z_k, \quad - e_j = \sum_{k} \sum_{i} \lambda_{k,i} \, B_{j,k,i} +For **strictly monotonic** breakpoints. Uses fill-fraction variables +:math:`\delta_i` with binary indicators --- no SOS2 needed: +.. math:: -.. _choosing-a-formulation: + &\delta_i \in [0, 1], \quad \delta_{i+1} \le \delta_i -Choosing a Formulation -~~~~~~~~~~~~~~~~~~~~~~ + &e_j = B_{j,0} + \sum_{i=1}^{n} \delta_i \, (B_{j,i} - B_{j,i-1}) -Pass ``method="auto"`` (the default) and linopy picks the best formulation: +.. code-block:: python -- **All breakpoints monotonic** -> incremental -- Otherwise -> SOS2 -- Disjunctive (segments) -> always SOS2 with binary selection -- **Inequality** -> use ``tangent_lines`` + regular constraints + m.add_piecewise_constraints((power, xp), (fuel, yp), method="incremental") +**Limitation:** All breakpoint sequences must be strictly monotonic. -Usage Examples --------------- +Disjunctive (Disaggregated Convex Combination) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -2-variable equality -~~~~~~~~~~~~~~~~~~~ +For **disconnected segments** (gaps between operating regions). Binary +indicators :math:`z_k` select exactly one segment; SOS2 applies within it: -.. code-block:: python +.. math:: - m.add_piecewise_constraints( - (power, linopy.breakpoints([0, 30, 60, 100])), - (fuel, linopy.breakpoints([0, 36, 84, 170])), - ) + &z_k \in \{0, 1\}, \quad \sum_{k} z_k = 1 -N-variable linking -~~~~~~~~~~~~~~~~~~ + &\sum_{i} \lambda_{k,i} = z_k, \qquad + e_j = \sum_{k} \sum_{i} \lambda_{k,i} \, B_{j,k,i} -.. code-block:: python +No big-M constants are needed, giving a tight LP relaxation. - m.add_piecewise_constraints( - (power, [0, 30, 60, 100]), - (fuel, [0, 40, 85, 160]), - (heat, [0, 25, 55, 95]), - ) +Tangent lines +~~~~~~~~~~~~~ -Inequality via tangent lines -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +For inequality bounds. Computes one tangent line per segment: -.. code-block:: python +.. math:: - t = linopy.tangent_lines(power, x_pts, y_pts) - m.add_constraints(fuel <= t) # upper bound (concave function) - m.add_constraints(fuel >= t) # lower bound (convex function) + \text{tangent}_k(x) = m_k \cdot x + c_k -Disjunctive (disconnected segments) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +where :math:`m_k` is the slope and :math:`c_k` the intercept of +segment :math:`k`. Returns a ``LinearExpression`` --- no variables +created. .. code-block:: python - m.add_piecewise_constraints( - (x, linopy.segments([(0, 10), (50, 100)])), - (y, linopy.segments([(0, 15), (60, 130)])), - ) - -Choosing a method -~~~~~~~~~~~~~~~~~ + t = linopy.tangent_lines(power, x_pts, y_pts) + m.add_constraints(fuel <= t) -.. code-block:: python - m.add_piecewise_constraints((x, xp), (y, yp), method="sos2") - m.add_piecewise_constraints((x, xp), (y, yp), method="incremental") - m.add_piecewise_constraints((x, xp), (y, yp), method="auto") # default +Advanced Features +----------------- Active parameter (unit commitment) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The ``active`` parameter gates the piecewise function with a binary variable. -When ``active=0``, all auxiliary variables are forced to zero. +The ``active`` parameter gates the piecewise function with a binary +variable. When ``active=0``, all auxiliary variables (and thus the +linked expressions) are forced to zero: .. code-block:: python commit = m.add_variables(name="commit", binary=True, coords=[time]) m.add_piecewise_constraints( - (power, x_pts), - (fuel, y_pts), + (power, [30, 60, 100]), + (fuel, [40, 90, 170]), active=commit, ) - -Breakpoints and Segments Factories ------------------------------------ - -:func:`~linopy.piecewise.breakpoints` creates DataArrays with the correct -``_breakpoint`` dimension. Accepts lists, Series, DataFrames, dicts, or -DataArrays: - -.. code-block:: python - - linopy.breakpoints([0, 50, 100]) # from list - linopy.breakpoints({"gen1": [0, 50], "gen2": [0, 80]}, dim="gen") # per-entity - linopy.breakpoints(slopes=[1.2, 1.4], x_points=[0, 30, 60], y0=0) # from slopes - -:func:`~linopy.piecewise.segments` creates DataArrays with both ``_segment`` -and ``_breakpoint`` dimensions for disjunctive formulations: - -.. code-block:: python - - linopy.segments([(0, 10), (50, 100)]) # from list - linopy.segments({"gen1": [(0, 10)], "gen2": [(0, 80)]}, dim="gen") # per-entity - +- ``commit=1``: power operates in [30, 100], fuel = f(power) +- ``commit=0``: power = 0, fuel = 0 Auto-broadcasting ------------------ +~~~~~~~~~~~~~~~~~ Breakpoints are automatically broadcast to match expression dimensions. -You don't need ``expand_dims`` when your variables have extra dimensions: +You don't need ``expand_dims``: .. code-block:: python @@ -312,81 +340,42 @@ You don't need ``expand_dims`` when your variables have extra dimensions: # 1D breakpoints auto-expand to match x's time dimension m.add_piecewise_constraints((x, [0, 50, 100]), (y, [0, 70, 150])) +NaN masking +~~~~~~~~~~~ -Generated Variables and Constraints ------------------------------------- +Trailing NaN values in breakpoints mask the corresponding lambda/delta +variables. This is useful for per-entity breakpoints with ragged +lengths: -Given base name ``name``, the following objects are created: +.. code-block:: python -**SOS2 method:** + # gen1 has 3 breakpoints, gen2 has 2 (NaN-padded) + bp = linopy.breakpoints({"gen1": [0, 50, 100], "gen2": [0, 80]}, dim="gen") -.. list-table:: - :header-rows: 1 - :widths: 30 15 55 - - * - Name - - Type - - Description - * - ``{name}_lambda`` - - Variable - - Interpolation weights :math:`\lambda_i \in [0, 1]` (SOS2). - * - ``{name}_convex`` - - Constraint - - :math:`\sum_i \lambda_i = 1`. - * - ``{name}_x_link`` - - Constraint - - Linking: :math:`e_j = \sum_i \lambda_i \, B_{j,i}` for all expressions. - -**Incremental method:** +Interior NaN values (gaps in the middle) are not supported and raise +an error. -.. list-table:: - :header-rows: 1 - :widths: 30 15 55 - - * - Name - - Type - - Description - * - ``{name}_delta`` - - Variable - - Fill-fraction variables :math:`\delta_i \in [0, 1]`. - * - ``{name}_inc_binary`` - - Variable - - Binary indicators for each segment. - * - ``{name}_fill`` - - Constraint - - :math:`\delta_{i+1} \le \delta_i` (fill order). - * - ``{name}_x_link`` - - Constraint - - Linking: :math:`e_j = B_{j,0} + \sum_i \delta_i \, \Delta B_{j,i}`. - -**Disjunctive method:** +Generated variables and constraints +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. list-table:: - :header-rows: 1 - :widths: 30 15 55 - - * - Name - - Type - - Description - * - ``{name}_binary`` - - Variable - - Segment indicators :math:`z_k \in \{0, 1\}`. - * - ``{name}_select`` - - Constraint - - :math:`\sum_k z_k = 1`. - * - ``{name}_lambda`` - - Variable - - Per-segment interpolation weights (SOS2). - * - ``{name}_convex`` - - Constraint - - :math:`\sum_i \lambda_{k,i} = z_k`. - * - ``{name}_x_link`` - - Constraint - - :math:`e_j = \sum_k \sum_i \lambda_{k,i} \, B_{j,k,i}`. +Given base name ``name``: + +**SOS2:** +``{name}_lambda`` (variable), ``{name}_convex`` (constraint), +``{name}_x_link`` (constraint) + +**Incremental:** +``{name}_delta`` (variable), ``{name}_inc_binary`` (variable), +``{name}_fill`` (constraint), ``{name}_x_link`` (constraint) + +**Disjunctive:** +``{name}_binary`` (variable), ``{name}_select`` (constraint), +``{name}_lambda`` (variable), ``{name}_convex`` (constraint), +``{name}_x_link`` (constraint) See Also -------- -- :doc:`piecewise-linear-constraints-tutorial` -- Worked examples (notebook) -- :doc:`sos-constraints` -- Low-level SOS1/SOS2 constraint API +- :doc:`piecewise-linear-constraints-tutorial` --- Worked examples (notebook) +- :doc:`sos-constraints` --- Low-level SOS1/SOS2 constraint API diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index af913769..c6b9d786 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -603,6 +603,22 @@ def test_invalid_method_raises(self) -> None: method="invalid", # type: ignore ) + def test_mismatched_breakpoint_sizes_raises(self) -> None: + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + with pytest.raises(ValueError, match="same size"): + m.add_piecewise_constraints( + (x, [0, 10, 50]), + (y, [5, 10]), + ) + + def test_non_tuple_arg_raises(self) -> None: + m = Model() + x = m.add_variables(name="x") + with pytest.raises(TypeError, match="tuple"): + m.add_piecewise_constraints(x, [0, 10, 50]) # type: ignore + # =========================================================================== # Name generation From e05db0353ee412e2b7d23ed187bc8b9852907ed1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:05:06 +0200 Subject: [PATCH 24/30] fix: add type: ignore comments to resolve mypy errors Co-Authored-By: Claude Opus 4.6 (1M context) --- linopy/expressions.py | 4 ++-- linopy/variables.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/linopy/expressions.py b/linopy/expressions.py index 0031944d..88c2099d 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -654,7 +654,7 @@ def __le__(self, rhs: SideLike) -> Constraint: def __ge__(self, rhs: SideLike) -> Constraint: return self.to_constraint(GREATER_EQUAL, rhs) - def __eq__(self, rhs: SideLike) -> Constraint: + def __eq__(self, rhs: SideLike) -> Constraint: # type: ignore return self.to_constraint(EQUAL, rhs) def __gt__(self, other: Any) -> NotImplementedType: @@ -2336,7 +2336,7 @@ def merge( has_quad_expression = any(type(e) is QuadraticExpression for e in exprs) has_linear_expression = any(type(e) is LinearExpression for e in exprs) if cls is None: - cls = QuadraticExpression if has_quad_expression else LinearExpression + cls = QuadraticExpression if has_quad_expression else LinearExpression # type: ignore if cls is QuadraticExpression and dim == TERM_DIM and has_linear_expression: raise ValueError( diff --git a/linopy/variables.py b/linopy/variables.py index 692ef9ba..0dfca099 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -542,7 +542,7 @@ def __le__(self, other: SideLike) -> Constraint: def __ge__(self, other: SideLike) -> Constraint: return self.to_linexpr().__ge__(other) - def __eq__(self, other: SideLike) -> Constraint: + def __eq__(self, other: SideLike) -> Constraint: # type: ignore return self.to_linexpr().__eq__(other) def __gt__(self, other: Any) -> NotImplementedType: From 0cf6fe8fb3fec11daf8259e82d27d021d53c51f0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:07:14 +0000 Subject: [PATCH 25/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- doc/piecewise-linear-constraints.rst | 55 ++++++++++++++++++---------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index d4f3bfa7..0aa2ed4b 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -26,7 +26,7 @@ Quick Start # Link power and fuel via a piecewise linear curve m.add_piecewise_constraints( (power, [0, 30, 60, 100]), - (fuel, [0, 36, 84, 170]), + (fuel, [0, 36, 84, 170]), ) Each ``(expression, breakpoints)`` tuple pairs a variable with its @@ -47,9 +47,9 @@ API (expr1, breakpoints1), (expr2, breakpoints2), ..., - method="auto", # "auto", "sos2", or "incremental" - active=None, # binary variable to gate the constraint - name=None, # base name for generated variables/constraints + method="auto", # "auto", "sos2", or "incremental" + active=None, # binary variable to gate the constraint + name=None, # base name for generated variables/constraints skip_nan_check=False, ) @@ -71,8 +71,8 @@ linear algebra. Use it with regular ``add_constraints``: .. code-block:: python t = linopy.tangent_lines(power, x_pts, y_pts) - m.add_constraints(fuel <= t) # upper bound - m.add_constraints(fuel >= t) # lower bound + m.add_constraints(fuel <= t) # upper bound + m.add_constraints(fuel >= t) # lower bound ``breakpoints`` and ``segments`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -81,11 +81,11 @@ Factory functions that create DataArrays with the correct dimension names: .. code-block:: python - linopy.breakpoints([0, 50, 100]) # list - linopy.breakpoints({"gen1": [0, 50], "gen2": [0, 80]}, dim="gen") # per-entity - linopy.breakpoints(slopes=[1.2, 1.4], x_points=[0, 30, 60], y0=0) # from slopes - linopy.segments([(0, 10), (50, 100)]) # disjunctive - linopy.segments({"gen1": [(0, 10)], "gen2": [(0, 80)]}, dim="gen") # per-entity + linopy.breakpoints([0, 50, 100]) # list + linopy.breakpoints({"gen1": [0, 50], "gen2": [0, 80]}, dim="gen") # per-entity + linopy.breakpoints(slopes=[1.2, 1.4], x_points=[0, 30, 60], y0=0) # from slopes + linopy.segments([(0, 10), (50, 100)]) # disjunctive + linopy.segments({"gen1": [(0, 10)], "gen2": [(0, 80)]}, dim="gen") # per-entity When to Use What @@ -140,7 +140,7 @@ The simplest form --- pass Python lists directly in the tuple: m.add_piecewise_constraints( (power, [0, 30, 60, 100]), - (fuel, [0, 36, 84, 170]), + (fuel, [0, 36, 84, 170]), ) With the ``breakpoints()`` factory @@ -152,7 +152,7 @@ Equivalent, but explicit about the DataArray construction: m.add_piecewise_constraints( (power, linopy.breakpoints([0, 30, 60, 100])), - (fuel, linopy.breakpoints([0, 36, 84, 170])), + (fuel, linopy.breakpoints([0, 36, 84, 170])), ) From slopes @@ -164,7 +164,12 @@ When you know marginal costs (slopes) rather than absolute values: m.add_piecewise_constraints( (power, [0, 50, 100, 150]), - (cost, linopy.breakpoints(slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0)), + ( + cost, + linopy.breakpoints( + slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0 + ), + ), ) # cost breakpoints: [0, 55, 130, 225] @@ -177,8 +182,18 @@ Different generators can have different curves. Pass a dict to .. code-block:: python m.add_piecewise_constraints( - (power, linopy.breakpoints({"gas": [0, 30, 60, 100], "coal": [0, 50, 100, 150]}, dim="gen")), - (fuel, linopy.breakpoints({"gas": [0, 40, 90, 180], "coal": [0, 55, 130, 225]}, dim="gen")), + ( + power, + linopy.breakpoints( + {"gas": [0, 30, 60, 100], "coal": [0, 50, 100, 150]}, dim="gen" + ), + ), + ( + fuel, + linopy.breakpoints( + {"gas": [0, 40, 90, 180], "coal": [0, 55, 130, 225]}, dim="gen" + ), + ), ) Ragged lengths are NaN-padded automatically. Breakpoints are @@ -194,7 +209,7 @@ For disconnected operating regions (e.g. forbidden zones), use m.add_piecewise_constraints( (power, linopy.segments([(0, 0), (50, 80)])), - (cost, linopy.segments([(0, 0), (125, 200)])), + (cost, linopy.segments([(0, 0), (125, 200)])), ) The disjunctive formulation is selected automatically when breakpoints @@ -210,8 +225,8 @@ are symmetric --- there is no distinguished "x" or "y": m.add_piecewise_constraints( (power, [0, 30, 60, 100]), - (fuel, [0, 40, 85, 160]), - (heat, [0, 25, 55, 95]), + (fuel, [0, 40, 85, 160]), + (heat, [0, 25, 55, 95]), ) @@ -318,7 +333,7 @@ linked expressions) are forced to zero: commit = m.add_variables(name="commit", binary=True, coords=[time]) m.add_piecewise_constraints( (power, [30, 60, 100]), - (fuel, [40, 90, 170]), + (fuel, [40, 90, 170]), active=commit, ) From afa1d8e902c1623409502420e5e67e76c06b6b7b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:31:50 +0200 Subject: [PATCH 26/30] refac: remove dead code --- linopy/piecewise.py | 155 -------------------------------------------- 1 file changed, 155 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 1bad2eab..5c3e40cd 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -576,161 +576,6 @@ def _compute_combined_mask( return ~(x_points.isnull() | y_points.isnull()) -def _add_pwl_sos2_core( - model: Model, - name: str, - x_expr: LinearExpression, - target_expr: LinearExpression, - x_points: DataArray, - y_points: DataArray, - lambda_mask: DataArray | None, - active: LinearExpression | None = None, -) -> Constraint: - """ - Core SOS2 formulation linking x_expr and target_expr via breakpoints. - - Creates lambda variables, SOS2 constraint, convexity constraint, - and linking constraints for both x and target. - - When ``active`` is provided, the convexity constraint becomes - ``sum(lambda) == active`` instead of ``== 1``, forcing all lambda - (and thus x, y) to zero when ``active=0``. - """ - extra = _extra_coords(x_points, BREAKPOINT_DIM) - lambda_coords = extra + [ - pd.Index(x_points.coords[BREAKPOINT_DIM].values, name=BREAKPOINT_DIM) - ] - - lambda_name = f"{name}{PWL_LAMBDA_SUFFIX}" - convex_name = f"{name}{PWL_CONVEX_SUFFIX}" - x_link_name = f"{name}{PWL_X_LINK_SUFFIX}" - y_link_name = f"{name}{PWL_Y_LINK_SUFFIX}" - - lambda_var = model.add_variables( - lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=lambda_mask - ) - - model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=BREAKPOINT_DIM) - - # Convexity constraint: sum(lambda) == 1 or sum(lambda) == active - rhs = active if active is not None else 1 - convex_con = model.add_constraints( - lambda_var.sum(dim=BREAKPOINT_DIM) == rhs, name=convex_name - ) - - x_weighted = (lambda_var * x_points).sum(dim=BREAKPOINT_DIM) - model.add_constraints(x_expr == x_weighted, name=x_link_name) - - y_weighted = (lambda_var * y_points).sum(dim=BREAKPOINT_DIM) - model.add_constraints(target_expr == y_weighted, name=y_link_name) - - return convex_con - - -def _add_pwl_incremental_core( - model: Model, - name: str, - x_expr: LinearExpression, - target_expr: LinearExpression, - x_points: DataArray, - y_points: DataArray, - bp_mask: DataArray | None, - active: LinearExpression | None = None, -) -> Constraint: - """ - Core incremental formulation linking x_expr and target_expr. - - Creates delta variables, fill-order constraints, and x/target link constraints. - - When ``active`` is provided, delta bounds are tightened to - ``δ_i ≤ active`` and base terms become ``x₀ * active``, - ``y₀ * active``, forcing x and y to zero when ``active=0``. - """ - delta_name = f"{name}{PWL_DELTA_SUFFIX}" - fill_name = f"{name}{PWL_FILL_SUFFIX}" - x_link_name = f"{name}{PWL_X_LINK_SUFFIX}" - y_link_name = f"{name}{PWL_Y_LINK_SUFFIX}" - - n_segments = x_points.sizes[BREAKPOINT_DIM] - 1 - seg_index = pd.Index(range(n_segments), name=LP_SEG_DIM) - extra = _extra_coords(x_points, BREAKPOINT_DIM) - delta_coords = extra + [seg_index] - - x_steps = x_points.diff(BREAKPOINT_DIM).rename({BREAKPOINT_DIM: LP_SEG_DIM}) - x_steps[LP_SEG_DIM] = seg_index - y_steps = y_points.diff(BREAKPOINT_DIM).rename({BREAKPOINT_DIM: LP_SEG_DIM}) - y_steps[LP_SEG_DIM] = seg_index - - if bp_mask is not None: - mask_lo = bp_mask.isel({BREAKPOINT_DIM: slice(None, -1)}).rename( - {BREAKPOINT_DIM: LP_SEG_DIM} - ) - mask_hi = bp_mask.isel({BREAKPOINT_DIM: slice(1, None)}).rename( - {BREAKPOINT_DIM: LP_SEG_DIM} - ) - mask_lo[LP_SEG_DIM] = seg_index - mask_hi[LP_SEG_DIM] = seg_index - delta_mask: DataArray | None = mask_lo & mask_hi - else: - delta_mask = None - - # When active is provided, upper bound is active (binary) instead of 1 - delta_upper = 1 - delta_var = model.add_variables( - lower=0, - upper=delta_upper, - coords=delta_coords, - name=delta_name, - mask=delta_mask, - ) - - if active is not None: - # Tighten delta bounds: δ_i ≤ active - active_bound_name = f"{name}{PWL_ACTIVE_BOUND_SUFFIX}" - model.add_constraints(delta_var <= active, name=active_bound_name) - - # Binary indicator variables: y_i for each segment - inc_binary_name = f"{name}{PWL_INC_BINARY_SUFFIX}" - inc_link_name = f"{name}{PWL_INC_LINK_SUFFIX}" - inc_order_name = f"{name}{PWL_INC_ORDER_SUFFIX}" - - binary_var = model.add_variables( - binary=True, coords=delta_coords, name=inc_binary_name, mask=delta_mask - ) - - # Link constraints: δ_i ≤ y_i for all segments - model.add_constraints(delta_var <= binary_var, name=inc_link_name) - - # Order constraints: y_{i+1} ≤ δ_i for i = 0..n-2 - fill_con: Constraint | None = None - if n_segments >= 2: - delta_lo = delta_var.isel({LP_SEG_DIM: slice(None, -1)}, drop=True) - delta_hi = delta_var.isel({LP_SEG_DIM: slice(1, None)}, drop=True) - # Keep existing fill constraint as LP relaxation tightener - fill_con = model.add_constraints(delta_hi <= delta_lo, name=fill_name) - - binary_hi = binary_var.isel({LP_SEG_DIM: slice(1, None)}, drop=True) - model.add_constraints(binary_hi <= delta_lo, name=inc_order_name) - - x0 = x_points.isel({BREAKPOINT_DIM: 0}) - y0 = y_points.isel({BREAKPOINT_DIM: 0}) - - # When active is provided, multiply base terms by active - x_base: DataArray | LinearExpression = x0 - y_base: DataArray | LinearExpression = y0 - if active is not None: - x_base = x0 * active - y_base = y0 * active - - x_weighted = (delta_var * x_steps).sum(dim=LP_SEG_DIM) + x_base - model.add_constraints(x_expr == x_weighted, name=x_link_name) - - y_weighted = (delta_var * y_steps).sum(dim=LP_SEG_DIM) + y_base - model.add_constraints(target_expr == y_weighted, name=y_link_name) - - return fill_con if fill_con is not None else model.constraints[y_link_name] - - def _add_dpwl_sos2_core( model: Model, name: str, From 07b7c164f318dc6e16a128c368811fbd7e97fe73 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:34:00 +0200 Subject: [PATCH 27/30] refac: inline _add_dpwl_sos2_core into _add_disjunctive, remove dead code Remove _add_pwl_sos2_core and _add_pwl_incremental_core which were never called, and inline the single-caller _add_dpwl_sos2_core. Co-Authored-By: Claude Opus 4.6 (1M context) --- linopy/piecewise.py | 111 +++++++++++++++++--------------------------- 1 file changed, 43 insertions(+), 68 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 5c3e40cd..7e42e9d5 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -576,72 +576,6 @@ def _compute_combined_mask( return ~(x_points.isnull() | y_points.isnull()) -def _add_dpwl_sos2_core( - model: Model, - name: str, - x_expr: LinearExpression, - target_expr: LinearExpression, - x_points: DataArray, - y_points: DataArray, - lambda_mask: DataArray | None, - active: LinearExpression | None = None, -) -> Constraint: - """ - Core disjunctive SOS2 formulation with separate x/y points. - - When ``active`` is provided, the segment selection becomes - ``sum(z_k) == active`` instead of ``== 1``, forcing all segment - binaries, lambdas, and thus x and y to zero when ``active=0``. - """ - binary_name = f"{name}{PWL_BINARY_SUFFIX}" - select_name = f"{name}{PWL_SELECT_SUFFIX}" - lambda_name = f"{name}{PWL_LAMBDA_SUFFIX}" - convex_name = f"{name}{PWL_CONVEX_SUFFIX}" - x_link_name = f"{name}{PWL_X_LINK_SUFFIX}" - y_link_name = f"{name}{PWL_Y_LINK_SUFFIX}" - - extra = _extra_coords(x_points, BREAKPOINT_DIM, SEGMENT_DIM) - lambda_coords = extra + [ - pd.Index(x_points.coords[SEGMENT_DIM].values, name=SEGMENT_DIM), - pd.Index(x_points.coords[BREAKPOINT_DIM].values, name=BREAKPOINT_DIM), - ] - binary_coords = extra + [ - pd.Index(x_points.coords[SEGMENT_DIM].values, name=SEGMENT_DIM), - ] - - binary_mask = ( - lambda_mask.any(dim=BREAKPOINT_DIM) if lambda_mask is not None else None - ) - - binary_var = model.add_variables( - binary=True, coords=binary_coords, name=binary_name, mask=binary_mask - ) - - # Segment selection: sum(z_k) == 1 or sum(z_k) == active - rhs = active if active is not None else 1 - select_con = model.add_constraints( - binary_var.sum(dim=SEGMENT_DIM) == rhs, name=select_name - ) - - lambda_var = model.add_variables( - lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=lambda_mask - ) - - model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=BREAKPOINT_DIM) - - model.add_constraints( - lambda_var.sum(dim=BREAKPOINT_DIM) == binary_var, name=convex_name - ) - - x_weighted = (lambda_var * x_points).sum(dim=[SEGMENT_DIM, BREAKPOINT_DIM]) - model.add_constraints(x_expr == x_weighted, name=x_link_name) - - y_weighted = (lambda_var * y_points).sum(dim=[SEGMENT_DIM, BREAKPOINT_DIM]) - model.add_constraints(target_expr == y_weighted, name=y_link_name) - - return select_con - - # --------------------------------------------------------------------------- # Main entry point # --------------------------------------------------------------------------- @@ -983,6 +917,47 @@ def _add_disjunctive( "NaN values must only appear at the end of the breakpoint sequence." ) - return _add_dpwl_sos2_core( - model, name, x_expr, y_expr, x_points, y_points, mask, active + binary_name = f"{name}{PWL_BINARY_SUFFIX}" + select_name = f"{name}{PWL_SELECT_SUFFIX}" + lambda_name = f"{name}{PWL_LAMBDA_SUFFIX}" + convex_name = f"{name}{PWL_CONVEX_SUFFIX}" + x_link_name = f"{name}{PWL_X_LINK_SUFFIX}" + y_link_name = f"{name}{PWL_Y_LINK_SUFFIX}" + + extra = _extra_coords(x_points, BREAKPOINT_DIM, SEGMENT_DIM) + lambda_coords = extra + [ + pd.Index(x_points.coords[SEGMENT_DIM].values, name=SEGMENT_DIM), + pd.Index(x_points.coords[BREAKPOINT_DIM].values, name=BREAKPOINT_DIM), + ] + binary_coords = extra + [ + pd.Index(x_points.coords[SEGMENT_DIM].values, name=SEGMENT_DIM), + ] + + binary_mask = mask.any(dim=BREAKPOINT_DIM) if mask is not None else None + + binary_var = model.add_variables( + binary=True, coords=binary_coords, name=binary_name, mask=binary_mask + ) + + rhs = active if active is not None else 1 + select_con = model.add_constraints( + binary_var.sum(dim=SEGMENT_DIM) == rhs, name=select_name + ) + + lambda_var = model.add_variables( + lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=mask ) + + model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=BREAKPOINT_DIM) + + model.add_constraints( + lambda_var.sum(dim=BREAKPOINT_DIM) == binary_var, name=convex_name + ) + + x_weighted = (lambda_var * x_points).sum(dim=[SEGMENT_DIM, BREAKPOINT_DIM]) + model.add_constraints(x_expr == x_weighted, name=x_link_name) + + y_weighted = (lambda_var * y_points).sum(dim=[SEGMENT_DIM, BREAKPOINT_DIM]) + model.add_constraints(y_expr == y_weighted, name=y_link_name) + + return select_con From e23f9346f7968c658142a3764c32c08a74df88a4 Mon Sep 17 00:00:00 2001 From: Felix <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:22:40 +0200 Subject: [PATCH 28/30] refac: clean up piecewise module (#641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refac: use _to_linexpr in tangent_lines instead of manual dispatch Co-Authored-By: Claude Opus 4.6 (1M context) * refac: rename _validate_xy_points to _validate_breakpoint_shapes Co-Authored-By: Claude Opus 4.6 (1M context) * refac: clean up duplicate section headers in piecewise.py Co-Authored-By: Claude Opus 4.6 (1M context) * refac: convert expressions once in _broadcast_points Co-Authored-By: Claude Opus 4.6 (1M context) * refac: remove unused _compute_combined_mask Co-Authored-By: Claude Opus 4.6 (1M context) * refac: validate method early, compute trailing_nan_only once Move method validation to add_piecewise_constraints entry point and avoid calling _has_trailing_nan_only multiple times on the same data. Co-Authored-By: Claude Opus 4.6 (1M context) * refac: deduplicate stacked mask expansion in _add_continuous_nvar Co-Authored-By: Claude Opus 4.6 (1M context) * refac: remove redundant isinstance guards in tangent_lines _coerce_breaks already returns DataArray inputs unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) * refac: rename _extra_coords to _var_coords_from with explicit exclude set Co-Authored-By: Claude Opus 4.6 (1M context) * refac: clarify transitive validation in breakpoint shape check Co-Authored-By: Claude Opus 4.6 (1M context) * refac: remove skip_nan_check parameter NaN breakpoints are always handled automatically via masking. The skip_nan_check flag added API surface for minimal value — it only asserted no NaN (misleading name) and skipped mask computation (negligible performance gain). Co-Authored-By: Claude Opus 4.6 (1M context) * refac: remove unused PWL_AUX/LP/LP_DOMAIN constants Remnants of the old LP method that was removed. Co-Authored-By: Claude Opus 4.6 (1M context) * refac: always return link constraint from incremental path Both SOS2 and incremental branches now consistently return the link constraint, making the return value predictable for callers. Co-Authored-By: Claude Opus 4.6 (1M context) * refac: split _add_continuous into _add_sos2 and _add_incremental Extract the SOS2 and incremental formulations into separate functions. Add _stack_along_link helper to deduplicate the expand+concat pattern. Co-Authored-By: Claude Opus 4.6 (1M context) * refac: rename test classes to match current function names TestPiecewiseEnvelope -> TestTangentLines TestSolverEnvelope -> TestSolverTangentLines Co-Authored-By: Claude Opus 4.6 (1M context) * refac: use _stack_along_link for expression stacking Co-Authored-By: Claude Opus 4.6 (1M context) * refac: use generic param names in _validate_breakpoint_shapes Rename x_points/y_points to bp_a/bp_b to reflect N-variable context. Co-Authored-By: Claude Opus 4.6 (1M context) * refac: extract _to_seg helper in tangent_lines for rename+reassign pattern Co-Authored-By: Claude Opus 4.6 (1M context) * refac: extract _strip_nan helper for NaN filtering in slopes mode Co-Authored-By: Claude Opus 4.6 (1M context) * refac: extract _breakpoints_from_slopes, add _to_seg docstring Move the ~50 line slopes-to-points conversion out of breakpoints() into _breakpoints_from_slopes, keeping breakpoints() as a clean validation-then-dispatch function. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: resolve mypy errors in _strip_nan and _stack_along_link types Co-Authored-By: Claude Opus 4.6 (1M context) * refac: remove duplicate slopes validation in breakpoints() Co-Authored-By: Claude Opus 4.6 (1M context) * refac: move _rename_to_segments to module level, fix extra blank line Co-Authored-By: Claude Opus 4.6 (1M context) * test: add validation and edge-case tests for piecewise module Cover error paths and edge cases: non-1D input, slopes mode with DataArray y0, non-numeric breakpoint coords, segment dim mismatch, disjunctive >2 pairs, disjunctive interior NaN, expression name fallback, incremental NaN masking, and scalar coord handling. Coverage: 92% -> 97% Co-Authored-By: Claude Opus 4.6 (1M context) * fix: resolve ruff and mypy errors - Use `X | Y` instead of `(X, Y)` in isinstance (UP038) - Remove unused `dim` variable in _add_continuous (F841) - Fix docstring formatting (D213) - Remove unnecessary type: ignore comment Co-Authored-By: Claude Opus 4.6 (1M context) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- doc/piecewise-linear-constraints.rst | 1 - linopy/constants.py | 3 - linopy/piecewise.py | 507 ++++++++++++++------------- test/test_piecewise_constraints.py | 195 +++++++++-- 4 files changed, 418 insertions(+), 288 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 0aa2ed4b..bb9eebbd 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -50,7 +50,6 @@ API method="auto", # "auto", "sos2", or "incremental" active=None, # binary variable to gate the constraint name=None, # base name for generated variables/constraints - skip_nan_check=False, ) Creates auxiliary variables and constraints that enforce all expressions diff --git a/linopy/constants.py b/linopy/constants.py index f3c05a55..0d8d4adc 100644 --- a/linopy/constants.py +++ b/linopy/constants.py @@ -46,9 +46,6 @@ PWL_FILL_SUFFIX = "_fill" PWL_BINARY_SUFFIX = "_binary" PWL_SELECT_SUFFIX = "_select" -PWL_AUX_SUFFIX = "_aux" -PWL_LP_SUFFIX = "_lp" -PWL_LP_DOMAIN_SUFFIX = "_lp_domain" PWL_INC_BINARY_SUFFIX = "_inc_binary" PWL_INC_LINK_SUFFIX = "_inc_link" PWL_INC_ORDER_SUFFIX = "_inc_order" diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 7e42e9d5..d9bd280b 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -60,6 +60,18 @@ # --------------------------------------------------------------------------- +def _strip_nan(vals: Sequence[float] | np.ndarray) -> list[float]: + """Remove NaN values from a sequence.""" + return [v for v in vals if not np.isnan(v)] + + +def _rename_to_segments(da: DataArray, seg_index: np.ndarray) -> DataArray: + """Rename breakpoint dim to segment dim and reassign coordinates.""" + da = da.rename({BREAKPOINT_DIM: LP_SEG_DIM}) + da[LP_SEG_DIM] = seg_index + return da + + def _sequence_to_array(values: Sequence[float]) -> DataArray: arr = np.asarray(values, dtype=float) if arr.ndim != 1: @@ -152,6 +164,59 @@ def _dict_segments_to_array( return combined +def _breakpoints_from_slopes( + slopes: BreaksLike, + x_points: BreaksLike, + y0: float | dict[str, float] | pd.Series | DataArray, + dim: str | None, +) -> DataArray: + """Convert slopes + x_points + y0 into a breakpoint DataArray.""" + slopes_arr = _coerce_breaks(slopes, dim) + xp_arr = _coerce_breaks(x_points, dim) + + # 1D case: single set of breakpoints + if slopes_arr.ndim == 1: + if not isinstance(y0, Real): + raise TypeError("When 'slopes' is 1D, 'y0' must be a scalar float") + pts = slopes_to_points(list(xp_arr.values), list(slopes_arr.values), float(y0)) + return _sequence_to_array(pts) + + # Multi-dim case: per-entity slopes + entity_dims = [d for d in slopes_arr.dims if d != BREAKPOINT_DIM] + if len(entity_dims) != 1: + raise ValueError( + f"Expected exactly one entity dimension in slopes, got {entity_dims}" + ) + entity_dim = str(entity_dims[0]) + entity_keys = slopes_arr.coords[entity_dim].values + + # Resolve y0 per entity + if isinstance(y0, Real): + y0_map: dict[str, float] = {str(k): float(y0) for k in entity_keys} + elif isinstance(y0, dict): + y0_map = {str(k): float(y0[k]) for k in entity_keys} + elif isinstance(y0, pd.Series): + y0_map = {str(k): float(y0[k]) for k in entity_keys} + elif isinstance(y0, DataArray): + y0_map = {str(k): float(y0.sel({entity_dim: k}).item()) for k in entity_keys} + else: + raise TypeError( + f"'y0' must be a float, Series, DataArray, or dict, got {type(y0)}" + ) + + computed: dict[str, Sequence[float]] = {} + for key in entity_keys: + sk = str(key) + sl = _strip_nan(slopes_arr.sel({entity_dim: key}).values) + if entity_dim in xp_arr.dims: + xp = _strip_nan(xp_arr.sel({entity_dim: key}).values) + else: + xp = _strip_nan(xp_arr.values) + computed[sk] = slopes_to_points(xp, sl, y0_map[sk]) + + return _dict_to_array(computed, entity_dim) + + # --------------------------------------------------------------------------- # Public factory functions # --------------------------------------------------------------------------- @@ -241,64 +306,7 @@ def breakpoints( if slopes is not None: if x_points is None or y0 is None: raise ValueError("'slopes' requires both 'x_points' and 'y0'") - - # Slopes mode: convert to points, then fall through to coerce - if slopes is not None: - if x_points is None or y0 is None: - raise ValueError("'slopes' requires both 'x_points' and 'y0'") - slopes_arr = _coerce_breaks(slopes, dim) - xp_arr = _coerce_breaks(x_points, dim) - - # 1D case: single set of breakpoints - if slopes_arr.ndim == 1: - if not isinstance(y0, Real): - raise TypeError("When 'slopes' is 1D, 'y0' must be a scalar float") - pts = slopes_to_points( - list(xp_arr.values), list(slopes_arr.values), float(y0) - ) - return _sequence_to_array(pts) - - # Multi-dim case: per-entity slopes - # Identify the entity dimension (not BREAKPOINT_DIM) - entity_dims = [d for d in slopes_arr.dims if d != BREAKPOINT_DIM] - if len(entity_dims) != 1: - raise ValueError( - f"Expected exactly one entity dimension in slopes, got {entity_dims}" - ) - entity_dim = str(entity_dims[0]) - entity_keys = slopes_arr.coords[entity_dim].values - - # Resolve y0 per entity - if isinstance(y0, Real): - y0_map: dict[str, float] = {str(k): float(y0) for k in entity_keys} - elif isinstance(y0, dict): - y0_map = {str(k): float(y0[k]) for k in entity_keys} - elif isinstance(y0, pd.Series): - y0_map = {str(k): float(y0[k]) for k in entity_keys} - elif isinstance(y0, DataArray): - y0_map = { - str(k): float(y0.sel({entity_dim: k}).item()) for k in entity_keys - } - else: - raise TypeError( - f"'y0' must be a float, Series, DataArray, or dict, got {type(y0)}" - ) - - # Compute points per entity - computed: dict[str, Sequence[float]] = {} - for key in entity_keys: - sk = str(key) - sl = list(slopes_arr.sel({entity_dim: key}).values) - # Remove trailing NaN from slopes - sl = [v for v in sl if not np.isnan(v)] - if entity_dim in xp_arr.dims: - xp = list(xp_arr.sel({entity_dim: key}).values) - xp = [v for v in xp if not np.isnan(v)] - else: - xp = [v for v in xp_arr.values if not np.isnan(v)] - computed[sk] = slopes_to_points(xp, sl, y0_map[sk]) - - return _dict_to_array(computed, entity_dim) + return _breakpoints_from_slopes(slopes, x_points, y0, dim) # Points mode if values is None: @@ -400,88 +408,71 @@ def tangent_lines( from linopy.expressions import LinearExpression as LinExpr from linopy.variables import Variable - if not isinstance(x_points, DataArray): - x_points = _coerce_breaks(x_points) - if not isinstance(y_points, DataArray): - y_points = _coerce_breaks(y_points) + x_points = _coerce_breaks(x_points) + y_points = _coerce_breaks(y_points) dx = x_points.diff(BREAKPOINT_DIM) dy = y_points.diff(BREAKPOINT_DIM) - slopes = dy / dx - - n_seg = slopes.sizes[BREAKPOINT_DIM] - seg_index = np.arange(n_seg) - - slopes = slopes.rename({BREAKPOINT_DIM: LP_SEG_DIM}) - slopes[LP_SEG_DIM] = seg_index + seg_index = np.arange(dx.sizes[BREAKPOINT_DIM]) - x_base = x_points.isel({BREAKPOINT_DIM: slice(None, -1)}).rename( - {BREAKPOINT_DIM: LP_SEG_DIM} + slopes = _rename_to_segments(dy / dx, seg_index) + x_base = _rename_to_segments( + x_points.isel({BREAKPOINT_DIM: slice(None, -1)}), seg_index ) - y_base = y_points.isel({BREAKPOINT_DIM: slice(None, -1)}).rename( - {BREAKPOINT_DIM: LP_SEG_DIM} + y_base = _rename_to_segments( + y_points.isel({BREAKPOINT_DIM: slice(None, -1)}), seg_index ) - x_base[LP_SEG_DIM] = seg_index - y_base[LP_SEG_DIM] = seg_index intercepts = y_base - slopes * x_base - if isinstance(x, Variable): - x_expr = x.to_linexpr() - elif isinstance(x, LinExpr): - x_expr = x - else: + if not isinstance(x, Variable | LinExpr): raise TypeError(f"x must be a Variable or LinearExpression, got {type(x)}") - return slopes * x_expr + intercepts + return slopes * _to_linexpr(x) + intercepts # --------------------------------------------------------------------------- -# Internal validation +# Internal validation and utility functions # --------------------------------------------------------------------------- -def _validate_xy_points(x_points: DataArray, y_points: DataArray) -> bool: - """Validate x/y breakpoint arrays and return whether formulation is disjunctive.""" - if BREAKPOINT_DIM not in x_points.dims: +def _validate_breakpoint_shapes(bp_a: DataArray, bp_b: DataArray) -> bool: + """ + Validate that two breakpoint arrays have compatible shapes. + + Returns whether the formulation is disjunctive (has segment dimension). + """ + if BREAKPOINT_DIM not in bp_a.dims: raise ValueError( - f"x_points is missing the '{BREAKPOINT_DIM}' dimension, " - f"got dims {list(x_points.dims)}. " + f"Breakpoints are missing the '{BREAKPOINT_DIM}' dimension, " + f"got dims {list(bp_a.dims)}. " "Use the breakpoints() or segments() factory." ) - if BREAKPOINT_DIM not in y_points.dims: + if BREAKPOINT_DIM not in bp_b.dims: raise ValueError( - f"y_points is missing the '{BREAKPOINT_DIM}' dimension, " - f"got dims {list(y_points.dims)}. " + f"Breakpoints are missing the '{BREAKPOINT_DIM}' dimension, " + f"got dims {list(bp_b.dims)}. " "Use the breakpoints() or segments() factory." ) - if x_points.sizes[BREAKPOINT_DIM] != y_points.sizes[BREAKPOINT_DIM]: + if bp_a.sizes[BREAKPOINT_DIM] != bp_b.sizes[BREAKPOINT_DIM]: raise ValueError( - f"x_points and y_points must have same size along '{BREAKPOINT_DIM}', " - f"got {x_points.sizes[BREAKPOINT_DIM]} and " - f"{y_points.sizes[BREAKPOINT_DIM]}" + f"Breakpoints must have same size along '{BREAKPOINT_DIM}', " + f"got {bp_a.sizes[BREAKPOINT_DIM]} and " + f"{bp_b.sizes[BREAKPOINT_DIM]}" ) - x_has_seg = SEGMENT_DIM in x_points.dims - y_has_seg = SEGMENT_DIM in y_points.dims - if x_has_seg != y_has_seg: - raise ValueError( - "If one of x_points/y_points has a segment dimension, " - f"both must. x_points dims: {list(x_points.dims)}, " - f"y_points dims: {list(y_points.dims)}." - ) - if x_has_seg and x_points.sizes[SEGMENT_DIM] != y_points.sizes[SEGMENT_DIM]: + a_has_seg = SEGMENT_DIM in bp_a.dims + b_has_seg = SEGMENT_DIM in bp_b.dims + if a_has_seg != b_has_seg: raise ValueError( - f"x_points and y_points must have same size along '{SEGMENT_DIM}'" + "If one breakpoint array has a segment dimension, " + f"both must. Got dims: {list(bp_a.dims)} and {list(bp_b.dims)}." ) + if a_has_seg and bp_a.sizes[SEGMENT_DIM] != bp_b.sizes[SEGMENT_DIM]: + raise ValueError(f"Breakpoints must have same size along '{SEGMENT_DIM}'") - return x_has_seg - - -# --------------------------------------------------------------------------- -# Internal validation and utility functions -# --------------------------------------------------------------------------- + return a_has_seg def _validate_numeric_breakpoint_coords(bp: DataArray) -> None: @@ -520,8 +511,11 @@ def _to_linexpr(expr: LinExprLike) -> LinearExpression: return expr.to_linexpr() -def _extra_coords(points: DataArray, *exclude_dims: str | None) -> list[pd.Index]: - excluded = {d for d in exclude_dims if d is not None} +def _var_coords_from( + points: DataArray, exclude: set[str] | None = None +) -> list[pd.Index]: + """Extract pd.Index coords from points, excluding specified dimensions.""" + excluded = exclude or set() return [ pd.Index(points.coords[d].values, name=d) for d in points.dims @@ -539,9 +533,10 @@ def _broadcast_points( if disjunctive: skip.add(SEGMENT_DIM) + lin_exprs = [_to_linexpr(e) for e in exprs] + target_dims: set[str] = set() - for e in exprs: - le = _to_linexpr(e) + for le in lin_exprs: target_dims.update(str(d) for d in le.coord_dims) missing = target_dims - skip - {str(d) for d in points.dims} @@ -550,8 +545,7 @@ def _broadcast_points( expand_map: dict[str, list] = {} for d in missing: - for e in exprs: - le = _to_linexpr(e) + for le in lin_exprs: if d in le.coords: expand_map[str(d)] = list(le.coords[d].values) break @@ -561,21 +555,6 @@ def _broadcast_points( return points -def _compute_combined_mask( - x_points: DataArray, - y_points: DataArray, - skip_nan_check: bool, -) -> DataArray | None: - if skip_nan_check: - if bool(x_points.isnull().any()) or bool(y_points.isnull().any()): - raise ValueError( - "skip_nan_check=True but breakpoints contain NaN. " - "Either remove NaN values or set skip_nan_check=False." - ) - return None - return ~(x_points.isnull() | y_points.isnull()) - - # --------------------------------------------------------------------------- # Main entry point # --------------------------------------------------------------------------- @@ -587,7 +566,6 @@ def add_piecewise_constraints( method: Literal["sos2", "incremental", "auto"] = "auto", active: LinExprLike | None = None, name: str | None = None, - skip_nan_check: bool = False, ) -> Constraint: r""" Add piecewise linear equality constraints. @@ -629,13 +607,16 @@ def add_piecewise_constraints( ``active=0``, all auxiliary variables are forced to zero. name : str, optional Base name for generated variables/constraints. - skip_nan_check : bool, default False - If True, skip NaN detection in breakpoints. Returns ------- Constraint """ + if method not in ("sos2", "incremental", "auto"): + raise ValueError( + f"method must be 'sos2', 'incremental', or 'auto', got '{method}'" + ) + if len(pairs) < 2: raise TypeError( "add_piecewise_constraints() requires at least 2 " @@ -664,9 +645,10 @@ def add_piecewise_constraints( first_bp = coerced[0][1] disjunctive = SEGMENT_DIM in first_bp.dims - # Validate all breakpoint pairs have compatible shapes + # Validate all breakpoint pairs have compatible shapes. + # Checking each against the first is sufficient since the shape checks are transitive. for i in range(1, len(coerced)): - _validate_xy_points(first_bp, coerced[i][1]) + _validate_breakpoint_shapes(first_bp, coerced[i][1]) # Broadcast all breakpoints to match all expression dimensions all_exprs = [expr for expr, _ in coerced] @@ -675,19 +657,10 @@ def add_piecewise_constraints( ] # Compute combined mask from all breakpoints - if skip_nan_check: - for bp in bp_list: - if bool(bp.isnull().any()): - raise ValueError( - "skip_nan_check=True but breakpoints contain NaN. " - "Either remove NaN values or set skip_nan_check=False." - ) - bp_mask = None - else: - combined_null = bp_list[0].isnull() - for bp in bp_list[1:]: - combined_null = combined_null | bp.isnull() - bp_mask = ~combined_null if bool(combined_null.any()) else None + combined_null = bp_list[0].isnull() + for bp in bp_list[1:]: + combined_null = combined_null | bp.isnull() + bp_mask = ~combined_null if bool(combined_null.any()) else None # Name if name is None: @@ -728,7 +701,7 @@ def add_piecewise_constraints( ) # Continuous: stack into N-variable formulation - return _add_continuous_nvar( + return _add_continuous( model, name, lin_exprs, @@ -736,12 +709,23 @@ def add_piecewise_constraints( link_coords, bp_mask, method, - skip_nan_check, active_expr, ) -def _add_continuous_nvar( +def _stack_along_link( + items: Sequence[DataArray | xr.Dataset], + link_coords: list[str], + link_dim: str, +) -> DataArray: + """Expand and concatenate DataArrays/Datasets along a new link dimension.""" + expanded = [ + item.expand_dims({link_dim: [c]}) for item, c in zip(items, link_coords) + ] + return xr.concat(expanded, dim=link_dim, coords="minimal") # type: ignore + + +def _add_continuous( model: Model, name: str, lin_exprs: list[LinearExpression], @@ -749,31 +733,20 @@ def _add_continuous_nvar( link_coords: list[str], bp_mask: DataArray | None, method: str, - skip_nan_check: bool, active: LinearExpression | None = None, ) -> Constraint: - """Unified continuous piecewise equality for N expressions.""" + """Dispatch continuous piecewise equality to SOS2 or incremental.""" from linopy.expressions import LinearExpression - if method not in ("sos2", "incremental", "auto"): - raise ValueError( - f"method must be 'sos2', 'incremental', or 'auto', got '{method}'" - ) - - # Stack breakpoints into a single DataArray with a link dimension link_dim = "_pwl_var" - stacked_bp = xr.concat( - [bp.expand_dims({link_dim: [c]}) for bp, c in zip(bp_list, link_coords)], - dim=link_dim, - coords="minimal", - ) + stacked_bp = _stack_along_link(bp_list, link_coords, link_dim) - dim = BREAKPOINT_DIM + # Pre-compute properties used by multiple branches + trailing_nan_only = _has_trailing_nan_only(stacked_bp) # Auto-detect method if method in ("incremental", "auto"): is_monotonic = _check_strict_monotonicity(stacked_bp) - trailing_nan_only = _has_trailing_nan_only(stacked_bp) if method == "auto": method = "incremental" if (is_monotonic and trailing_nan_only) else "sos2" elif not is_monotonic: @@ -787,110 +760,142 @@ def _add_continuous_nvar( if method == "sos2": _validate_numeric_breakpoint_coords(stacked_bp) - if not _has_trailing_nan_only(stacked_bp): + if not trailing_nan_only: raise ValueError( "SOS2 method does not support non-trailing NaN breakpoints." ) # Stack expressions along the link dimension - expr_data_list = [ - e.data.expand_dims({link_dim: [c]}) for e, c in zip(lin_exprs, link_coords) - ] - stacked_data = xr.concat(expr_data_list, dim=link_dim, coords="minimal") + stacked_data = _stack_along_link([e.data for e in lin_exprs], link_coords, link_dim) target_expr = LinearExpression(stacked_data, model) - # Compute lambda mask - lambda_mask = None + # Compute stacked mask + stacked_mask = None if bp_mask is not None: - stacked_mask = xr.concat( - [bp_mask.expand_dims({link_dim: [c]}) for c in link_coords], - dim=link_dim, - coords="minimal", + stacked_mask = _stack_along_link( + [bp_mask] * len(link_coords), link_coords, link_dim ) - lambda_mask = stacked_mask.any(dim=link_dim) - extra = _extra_coords(stacked_bp, dim, link_dim) - lambda_coords = extra + [pd.Index(stacked_bp.coords[dim].values, name=dim)] - - # Convexity RHS: 1 or active rhs = active if active is not None else 1 if method == "sos2": - lambda_name = f"{name}{PWL_LAMBDA_SUFFIX}" - convex_name = f"{name}{PWL_CONVEX_SUFFIX}" - link_name = f"{name}{PWL_X_LINK_SUFFIX}" - - lambda_var = model.add_variables( - lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=lambda_mask + return _add_sos2( + model, + name, + target_expr, + stacked_bp, + stacked_mask, + link_dim, + rhs, ) - model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=dim) - model.add_constraints(lambda_var.sum(dim=dim) == rhs, name=convex_name) - - weighted_sum = (lambda_var * stacked_bp).sum(dim=dim) - return model.add_constraints(target_expr == weighted_sum, name=link_name) - - else: # incremental - delta_name = f"{name}{PWL_DELTA_SUFFIX}" - fill_name = f"{name}{PWL_FILL_SUFFIX}" - link_name = f"{name}{PWL_X_LINK_SUFFIX}" - inc_binary_name = f"{name}{PWL_INC_BINARY_SUFFIX}" - inc_link_name = f"{name}{PWL_INC_LINK_SUFFIX}" - inc_order_name = f"{name}{PWL_INC_ORDER_SUFFIX}" - - n_segments = stacked_bp.sizes[dim] - 1 - seg_dim = f"{dim}_seg" - seg_index = pd.Index(range(n_segments), name=seg_dim) - delta_extra = _extra_coords(stacked_bp, dim, link_dim) - delta_coords = delta_extra + [seg_index] - - steps = stacked_bp.diff(dim).rename({dim: seg_dim}) - steps[seg_dim] = seg_index - - if bp_mask is not None: - stacked_mask = xr.concat( - [bp_mask.expand_dims({link_dim: [c]}) for c in link_coords], - dim=link_dim, - coords="minimal", - ) - bp_mask_agg = stacked_mask.all(dim=link_dim) - mask_lo = bp_mask_agg.isel({dim: slice(None, -1)}).rename({dim: seg_dim}) - mask_hi = bp_mask_agg.isel({dim: slice(1, None)}).rename({dim: seg_dim}) - mask_lo[seg_dim] = seg_index - mask_hi[seg_dim] = seg_index - delta_mask: DataArray | None = mask_lo & mask_hi - else: - delta_mask = None - - delta_var = model.add_variables( - lower=0, upper=1, coords=delta_coords, name=delta_name, mask=delta_mask + else: + return _add_incremental( + model, + name, + target_expr, + stacked_bp, + stacked_mask, + link_dim, + rhs, + active, ) - if active is not None: - active_bound_name = f"{name}{PWL_ACTIVE_BOUND_SUFFIX}" - model.add_constraints(delta_var <= active, name=active_bound_name) - binary_var = model.add_variables( - binary=True, coords=delta_coords, name=inc_binary_name, mask=delta_mask - ) - model.add_constraints(delta_var <= binary_var, name=inc_link_name) +def _add_sos2( + model: Model, + name: str, + target_expr: LinearExpression, + stacked_bp: DataArray, + stacked_mask: DataArray | None, + link_dim: str, + rhs: LinearExpression | int, +) -> Constraint: + """SOS2 formulation for N-variable continuous piecewise equality.""" + dim = BREAKPOINT_DIM + extra = _var_coords_from(stacked_bp, exclude={dim, link_dim}) + lambda_mask = stacked_mask.any(dim=link_dim) if stacked_mask is not None else None + lambda_coords = extra + [pd.Index(stacked_bp.coords[dim].values, name=dim)] + + lambda_name = f"{name}{PWL_LAMBDA_SUFFIX}" + convex_name = f"{name}{PWL_CONVEX_SUFFIX}" + link_name = f"{name}{PWL_X_LINK_SUFFIX}" + + lambda_var = model.add_variables( + lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=lambda_mask + ) + model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=dim) + model.add_constraints(lambda_var.sum(dim=dim) == rhs, name=convex_name) - fill_con: Constraint | None = None - if n_segments >= 2: - delta_lo = delta_var.isel({seg_dim: slice(None, -1)}, drop=True) - delta_hi = delta_var.isel({seg_dim: slice(1, None)}, drop=True) - fill_con = model.add_constraints(delta_hi <= delta_lo, name=fill_name) + weighted_sum = (lambda_var * stacked_bp).sum(dim=dim) + return model.add_constraints(target_expr == weighted_sum, name=link_name) + + +def _add_incremental( + model: Model, + name: str, + target_expr: LinearExpression, + stacked_bp: DataArray, + stacked_mask: DataArray | None, + link_dim: str, + rhs: LinearExpression | int, + active: LinearExpression | None, +) -> Constraint: + """Incremental formulation for N-variable continuous piecewise equality.""" + dim = BREAKPOINT_DIM + extra = _var_coords_from(stacked_bp, exclude={dim, link_dim}) + + delta_name = f"{name}{PWL_DELTA_SUFFIX}" + fill_name = f"{name}{PWL_FILL_SUFFIX}" + link_name = f"{name}{PWL_X_LINK_SUFFIX}" + inc_binary_name = f"{name}{PWL_INC_BINARY_SUFFIX}" + inc_link_name = f"{name}{PWL_INC_LINK_SUFFIX}" + inc_order_name = f"{name}{PWL_INC_ORDER_SUFFIX}" + + n_segments = stacked_bp.sizes[dim] - 1 + seg_dim = f"{dim}_seg" + seg_index = pd.Index(range(n_segments), name=seg_dim) + delta_coords = extra + [seg_index] + + steps = stacked_bp.diff(dim).rename({dim: seg_dim}) + steps[seg_dim] = seg_index + + if stacked_mask is not None: + bp_mask_agg = stacked_mask.all(dim=link_dim) + mask_lo = bp_mask_agg.isel({dim: slice(None, -1)}).rename({dim: seg_dim}) + mask_hi = bp_mask_agg.isel({dim: slice(1, None)}).rename({dim: seg_dim}) + mask_lo[seg_dim] = seg_index + mask_hi[seg_dim] = seg_index + delta_mask: DataArray | None = mask_lo & mask_hi + else: + delta_mask = None + + delta_var = model.add_variables( + lower=0, upper=1, coords=delta_coords, name=delta_name, mask=delta_mask + ) + + if active is not None: + active_bound_name = f"{name}{PWL_ACTIVE_BOUND_SUFFIX}" + model.add_constraints(delta_var <= active, name=active_bound_name) + + binary_var = model.add_variables( + binary=True, coords=delta_coords, name=inc_binary_name, mask=delta_mask + ) + model.add_constraints(delta_var <= binary_var, name=inc_link_name) - binary_hi = binary_var.isel({seg_dim: slice(1, None)}, drop=True) - model.add_constraints(binary_hi <= delta_lo, name=inc_order_name) + if n_segments >= 2: + delta_lo = delta_var.isel({seg_dim: slice(None, -1)}, drop=True) + delta_hi = delta_var.isel({seg_dim: slice(1, None)}, drop=True) + model.add_constraints(delta_hi <= delta_lo, name=fill_name) - bp0 = stacked_bp.isel({dim: 0}) - bp0_term: DataArray | LinearExpression = bp0 - if active is not None: - bp0_term = bp0 * active - weighted_sum = (delta_var * steps).sum(dim=seg_dim) + bp0_term - link_con = model.add_constraints(target_expr == weighted_sum, name=link_name) + binary_hi = binary_var.isel({seg_dim: slice(1, None)}, drop=True) + model.add_constraints(binary_hi <= delta_lo, name=inc_order_name) - return fill_con if fill_con is not None else link_con + bp0 = stacked_bp.isel({dim: 0}) + bp0_term: DataArray | LinearExpression = bp0 + if active is not None: + bp0_term = bp0 * active + weighted_sum = (delta_var * steps).sum(dim=seg_dim) + bp0_term + return model.add_constraints(target_expr == weighted_sum, name=link_name) def _add_disjunctive( @@ -924,7 +929,7 @@ def _add_disjunctive( x_link_name = f"{name}{PWL_X_LINK_SUFFIX}" y_link_name = f"{name}{PWL_Y_LINK_SUFFIX}" - extra = _extra_coords(x_points, BREAKPOINT_DIM, SEGMENT_DIM) + extra = _var_coords_from(x_points, exclude={BREAKPOINT_DIM, SEGMENT_DIM}) lambda_coords = extra + [ pd.Index(x_points.coords[SEGMENT_DIM].values, name=SEGMENT_DIM), pd.Index(x_points.coords[BREAKPOINT_DIM].values, name=BREAKPOINT_DIM), diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index c6b9d786..23d1da66 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -358,7 +358,7 @@ def test_with_slopes(self) -> None: # =========================================================================== -class TestPiecewiseEnvelope: +class TestTangentLines: def test_basic_variable(self) -> None: """Envelope from a Variable produces a LinearExpression with seg dim.""" m = Model() @@ -705,37 +705,6 @@ def test_nan_masks_lambda_labels(self) -> None: assert (lam.labels.isel({BREAKPOINT_DIM: slice(None, 3)}) != -1).all() assert int(lam.labels.isel({BREAKPOINT_DIM: 3})) == -1 - def test_skip_nan_check_with_nan_raises(self) -> None: - """skip_nan_check=True with NaN breakpoints raises ValueError.""" - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - x_pts = xr.DataArray([0, 10, 50, np.nan], dims=[BREAKPOINT_DIM]) - y_pts = xr.DataArray([0, 5, 20, np.nan], dims=[BREAKPOINT_DIM]) - with pytest.raises(ValueError, match="skip_nan_check=True but breakpoints"): - m.add_piecewise_constraints( - (x, x_pts), - (y, y_pts), - method="sos2", - skip_nan_check=True, - ) - - def test_skip_nan_check_without_nan(self) -> None: - """skip_nan_check=True without NaN works fine (no mask computed).""" - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - x_pts = xr.DataArray([0, 10, 50, 100], dims=[BREAKPOINT_DIM]) - y_pts = xr.DataArray([0, 5, 20, 40], dims=[BREAKPOINT_DIM]) - m.add_piecewise_constraints( - (x, x_pts), - (y, y_pts), - method="sos2", - skip_nan_check=True, - ) - lam = m.variables[f"pwl0{PWL_LAMBDA_SUFFIX}"] - assert (lam.labels != -1).all() - def test_sos2_interior_nan_raises(self) -> None: """SOS2 with interior NaN breakpoints raises ValueError.""" m = Model() @@ -852,7 +821,7 @@ def test_disjunctive_solve(self, solver_name: str) -> None: @pytest.mark.skipif(len(_any_solvers) == 0, reason="No solver available") -class TestSolverEnvelope: +class TestSolverTangentLines: @pytest.fixture(params=_any_solvers) def solver_name(self, request: pytest.FixtureRequest) -> str: return request.param @@ -1222,3 +1191,163 @@ def test_custom_name(self) -> None: name="chp", ) assert f"chp{PWL_DELTA_SUFFIX}" in m.variables + + +# =========================================================================== +# Additional validation and edge-case coverage +# =========================================================================== + + +class TestValidationEdgeCases: + def test_non_1d_sequence_raises(self) -> None: + """breakpoints() with a 2D nested list raises ValueError.""" + with pytest.raises(ValueError, match="1D sequence"): + breakpoints([[1, 2], [3, 4]]) + + def test_breakpoints_no_values_no_slopes_raises(self) -> None: + """breakpoints() with neither values nor slopes raises.""" + with pytest.raises(ValueError, match="Must pass either"): + breakpoints() + + def test_slopes_1d_non_scalar_y0_raises(self) -> None: + """1D slopes with dict y0 raises TypeError.""" + with pytest.raises(TypeError, match="scalar float"): + breakpoints(slopes=[1, 2], x_points=[0, 10, 20], y0={"a": 0}) + + def test_slopes_bad_y0_type_raises(self) -> None: + """Slopes with unsupported y0 type raises TypeError.""" + with pytest.raises(TypeError, match="y0"): + breakpoints( + slopes={"a": [1, 2], "b": [3, 4]}, + x_points={"a": [0, 10, 20], "b": [0, 10, 20]}, + y0="bad", + dim="entity", + ) + + def test_slopes_dataarray_y0(self) -> None: + """Slopes mode with DataArray y0 works.""" + y0_da = xr.DataArray([0, 5], dims=["gen"], coords={"gen": ["a", "b"]}) + bp = breakpoints( + slopes={"a": [1, 2], "b": [3, 4]}, + x_points={"a": [0, 10, 20], "b": [0, 10, 20]}, + y0=y0_da, + dim="gen", + ) + assert BREAKPOINT_DIM in bp.dims + assert "gen" in bp.dims + + def test_non_numeric_breakpoint_coords_raises(self) -> None: + """SOS2 with string breakpoint coords raises ValueError.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + x_pts = xr.DataArray( + [0, 10, 50], + dims=[BREAKPOINT_DIM], + coords={BREAKPOINT_DIM: ["a", "b", "c"]}, + ) + y_pts = xr.DataArray( + [0, 5, 20], + dims=[BREAKPOINT_DIM], + coords={BREAKPOINT_DIM: ["a", "b", "c"]}, + ) + with pytest.raises(ValueError, match="numeric coordinates"): + m.add_piecewise_constraints( + (x, x_pts), + (y, y_pts), + method="sos2", + ) + + def test_missing_breakpoint_dim_on_second_arg_raises(self) -> None: + """Second breakpoint array missing breakpoint dim raises.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + good = xr.DataArray([0, 10, 50], dims=[BREAKPOINT_DIM]) + bad = xr.DataArray([0, 5, 20], dims=["wrong"]) + with pytest.raises(ValueError, match="missing"): + m.add_piecewise_constraints((x, good), (y, bad)) + + def test_segment_dim_mismatch_raises(self) -> None: + """Segment dim on only one breakpoint array raises.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + x_pts = segments([[0, 10], [50, 100]]) + y_pts = breakpoints([0, 5]) # same breakpoint count but no segment dim + with pytest.raises(ValueError, match="segment dimension"): + m.add_piecewise_constraints((x, x_pts), (y, y_pts)) + + def test_disjunctive_three_pairs_raises(self) -> None: + """Disjunctive with 3 pairs raises ValueError.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + z = m.add_variables(name="z") + seg = segments([[0, 10], [50, 100]]) + with pytest.raises(ValueError, match="exactly 2"): + m.add_piecewise_constraints( + (x, seg), + (y, seg), + (z, seg), + ) + + def test_disjunctive_interior_nan_raises(self) -> None: + """Disjunctive with interior NaN raises ValueError.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + # 3 breakpoints per segment, NaN in the middle of segment 0 + x_pts = xr.DataArray( + [[0, np.nan, 10], [50, 75, 100]], + dims=[SEGMENT_DIM, BREAKPOINT_DIM], + ) + y_pts = xr.DataArray( + [[0, np.nan, 5], [20, 50, 80]], + dims=[SEGMENT_DIM, BREAKPOINT_DIM], + ) + with pytest.raises(ValueError, match="non-trailing NaN"): + m.add_piecewise_constraints((x, x_pts), (y, y_pts)) + + def test_expression_name_fallback(self) -> None: + """LinExpr (not Variable) gets numeric name in link coords.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + # Non-monotonic so auto picks SOS2 (which creates lambda vars) + m.add_piecewise_constraints( + (1.0 * x, [0, 50, 10]), + (1.0 * y, [0, 20, 5]), + method="sos2", + ) + assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables + + def test_incremental_with_nan_mask(self) -> None: + """Incremental method with trailing NaN creates masked delta vars.""" + m = Model() + gens = pd.Index(["a", "b"], name="gen") + x = m.add_variables(coords=[gens], name="x") + y = m.add_variables(coords=[gens], name="y") + x_pts = breakpoints({"a": [0, 10, 50], "b": [0, 20]}, dim="gen") + y_pts = breakpoints({"a": [0, 5, 20], "b": [0, 8]}, dim="gen") + m.add_piecewise_constraints( + (x, x_pts), + (y, y_pts), + method="incremental", + ) + delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"] + assert delta.labels.shape[0] == 2 # 2 generators + + def test_scalar_coord_dropped(self) -> None: + """Scalar coords on breakpoints are dropped before stacking.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + bp = breakpoints([0, 10, 50]) + bp_with_scalar = bp.assign_coords(extra=42) + m.add_piecewise_constraints( + (x, bp_with_scalar), + (y, [0, 5, 20]), + method="sos2", + ) + assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables From 8cac1d7412d9e507ac8776cc7d999bbc4c567bd6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:21:01 +0200 Subject: [PATCH 29/30] feat: generalize disjunctive formulation to N variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor _add_disjunctive to use the same stacked N-variable pattern as _add_continuous. Removes the 2-variable restriction — disjunctive now supports any number of (expression, breakpoints) pairs with a single unified link constraint. - Remove separate x_link/y_link in favor of single _link with _pwl_var dim - Remove PWL_Y_LINK_SUFFIX import (no longer needed) - Add test for 3-variable disjunctive Co-Authored-By: Claude Opus 4.6 (1M context) --- linopy/piecewise.py | 98 ++++++++++++++++-------------- test/test_piecewise_constraints.py | 35 ++++++++--- 2 files changed, 80 insertions(+), 53 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index d9bd280b..c5de3563 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -31,7 +31,6 @@ PWL_LAMBDA_SUFFIX, PWL_SELECT_SUFFIX, PWL_X_LINK_SUFFIX, - PWL_Y_LINK_SUFFIX, SEGMENT_DIM, ) @@ -682,21 +681,17 @@ def add_piecewise_constraints( active_expr = _to_linexpr(active) if active is not None else None if disjunctive: - # Disjunctive only supports 2-variable for now - if len(coerced) != 2: + if method == "incremental": raise ValueError( - "Disjunctive piecewise constraints currently support " - "exactly 2 (expression, breakpoints) pairs." + "Incremental method is not supported for disjunctive constraints" ) return _add_disjunctive( model, name, - lin_exprs[0], - lin_exprs[1], - bp_list[0], - bp_list[1], + lin_exprs, + bp_list, + link_coords, bp_mask, - method, active_expr, ) @@ -901,68 +896,81 @@ def _add_incremental( def _add_disjunctive( model: Model, name: str, - x_expr: LinearExpression, - y_expr: LinearExpression, - x_points: DataArray, - y_points: DataArray, - mask: DataArray | None, - method: str, + lin_exprs: list[LinearExpression], + bp_list: list[DataArray], + link_coords: list[str], + bp_mask: DataArray | None, active: LinearExpression | None = None, ) -> Constraint: - """Handle disjunctive piecewise equality constraints (2-variable only).""" - if method == "incremental": - raise ValueError( - "Incremental method is not supported for disjunctive constraints" - ) + """Disjunctive SOS2 formulation for N-variable piecewise equality.""" + from linopy.expressions import LinearExpression - _validate_numeric_breakpoint_coords(x_points) - if not _has_trailing_nan_only(x_points): + link_dim = "_pwl_var" + stacked_bp = _stack_along_link(bp_list, link_coords, link_dim) + + _validate_numeric_breakpoint_coords(stacked_bp) + if not _has_trailing_nan_only(stacked_bp): raise ValueError( "Disjunctive SOS2 does not support non-trailing NaN breakpoints. " "NaN values must only appear at the end of the breakpoint sequence." ) - binary_name = f"{name}{PWL_BINARY_SUFFIX}" - select_name = f"{name}{PWL_SELECT_SUFFIX}" - lambda_name = f"{name}{PWL_LAMBDA_SUFFIX}" - convex_name = f"{name}{PWL_CONVEX_SUFFIX}" - x_link_name = f"{name}{PWL_X_LINK_SUFFIX}" - y_link_name = f"{name}{PWL_Y_LINK_SUFFIX}" + # Stack expressions along link dimension + stacked_data = _stack_along_link( + [e.data for e in lin_exprs], link_coords, link_dim + ) + target_expr = LinearExpression(stacked_data, model) - extra = _var_coords_from(x_points, exclude={BREAKPOINT_DIM, SEGMENT_DIM}) + # Compute stacked mask + stacked_mask = None + if bp_mask is not None: + stacked_mask = _stack_along_link( + [bp_mask] * len(link_coords), link_coords, link_dim + ) + + dim = BREAKPOINT_DIM + extra = _var_coords_from(stacked_bp, exclude={dim, SEGMENT_DIM, link_dim}) lambda_coords = extra + [ - pd.Index(x_points.coords[SEGMENT_DIM].values, name=SEGMENT_DIM), - pd.Index(x_points.coords[BREAKPOINT_DIM].values, name=BREAKPOINT_DIM), + pd.Index(stacked_bp.coords[SEGMENT_DIM].values, name=SEGMENT_DIM), + pd.Index(stacked_bp.coords[dim].values, name=dim), ] binary_coords = extra + [ - pd.Index(x_points.coords[SEGMENT_DIM].values, name=SEGMENT_DIM), + pd.Index(stacked_bp.coords[SEGMENT_DIM].values, name=SEGMENT_DIM), ] - binary_mask = mask.any(dim=BREAKPOINT_DIM) if mask is not None else None + # Masks + lambda_mask = None + binary_mask = None + if stacked_mask is not None: + # Aggregate across link_dim — all variables must be valid + agg_mask = stacked_mask.all(dim=link_dim) + lambda_mask = agg_mask + binary_mask = agg_mask.any(dim=dim) + + binary_name = f"{name}{PWL_BINARY_SUFFIX}" + select_name = f"{name}{PWL_SELECT_SUFFIX}" + lambda_name = f"{name}{PWL_LAMBDA_SUFFIX}" + convex_name = f"{name}{PWL_CONVEX_SUFFIX}" + link_name = f"{name}{PWL_X_LINK_SUFFIX}" binary_var = model.add_variables( binary=True, coords=binary_coords, name=binary_name, mask=binary_mask ) rhs = active if active is not None else 1 - select_con = model.add_constraints( + model.add_constraints( binary_var.sum(dim=SEGMENT_DIM) == rhs, name=select_name ) lambda_var = model.add_variables( - lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=mask + lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=lambda_mask ) - model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=BREAKPOINT_DIM) + model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=dim) model.add_constraints( - lambda_var.sum(dim=BREAKPOINT_DIM) == binary_var, name=convex_name + lambda_var.sum(dim=dim) == binary_var, name=convex_name ) - x_weighted = (lambda_var * x_points).sum(dim=[SEGMENT_DIM, BREAKPOINT_DIM]) - model.add_constraints(x_expr == x_weighted, name=x_link_name) - - y_weighted = (lambda_var * y_points).sum(dim=[SEGMENT_DIM, BREAKPOINT_DIM]) - model.add_constraints(y_expr == y_weighted, name=y_link_name) - - return select_con + weighted = (lambda_var * stacked_bp).sum(dim=[SEGMENT_DIM, dim]) + return model.add_constraints(target_expr == weighted, name=link_name) diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 23d1da66..dbc038fd 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -579,6 +579,23 @@ def test_multi_dimensional(self) -> None: assert "generator" in binary.dims assert "generator" in lam.dims + def test_three_variables(self) -> None: + """Disjunctive with 3 variables creates single link constraint.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + z = m.add_variables(name="z") + m.add_piecewise_constraints( + (x, segments([[0, 10], [50, 100]])), + (y, segments([[0, 5], [20, 80]])), + (z, segments([[0, 3], [15, 60]])), + ) + assert f"pwl0{PWL_BINARY_SUFFIX}" in m.variables + assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables + # Single link constraint with _pwl_var dimension + link = m.constraints[f"pwl0{PWL_X_LINK_SUFFIX}"] + assert "_pwl_var" in [str(d) for d in link.dims] + # =========================================================================== # Validation @@ -1278,19 +1295,21 @@ def test_segment_dim_mismatch_raises(self) -> None: with pytest.raises(ValueError, match="segment dimension"): m.add_piecewise_constraints((x, x_pts), (y, y_pts)) - def test_disjunctive_three_pairs_raises(self) -> None: - """Disjunctive with 3 pairs raises ValueError.""" + def test_disjunctive_three_pairs(self) -> None: + """Disjunctive with 3 pairs works (N-variable).""" m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") z = m.add_variables(name="z") seg = segments([[0, 10], [50, 100]]) - with pytest.raises(ValueError, match="exactly 2"): - m.add_piecewise_constraints( - (x, seg), - (y, seg), - (z, seg), - ) + m.add_piecewise_constraints( + (x, seg), + (y, seg), + (z, seg), + ) + assert f"pwl0{PWL_BINARY_SUFFIX}" in m.variables + assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables + assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints def test_disjunctive_interior_nan_raises(self) -> None: """Disjunctive with interior NaN raises ValueError.""" From 1dd2f4a0e5a978abab6f6bb06f82cace27c9b5e8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:21:19 +0000 Subject: [PATCH 30/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- linopy/piecewise.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index c5de3563..e06664b3 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -916,9 +916,7 @@ def _add_disjunctive( ) # Stack expressions along link dimension - stacked_data = _stack_along_link( - [e.data for e in lin_exprs], link_coords, link_dim - ) + stacked_data = _stack_along_link([e.data for e in lin_exprs], link_coords, link_dim) target_expr = LinearExpression(stacked_data, model) # Compute stacked mask @@ -958,9 +956,7 @@ def _add_disjunctive( ) rhs = active if active is not None else 1 - model.add_constraints( - binary_var.sum(dim=SEGMENT_DIM) == rhs, name=select_name - ) + model.add_constraints(binary_var.sum(dim=SEGMENT_DIM) == rhs, name=select_name) lambda_var = model.add_variables( lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=lambda_mask @@ -968,9 +964,7 @@ def _add_disjunctive( model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=dim) - model.add_constraints( - lambda_var.sum(dim=dim) == binary_var, name=convex_name - ) + model.add_constraints(lambda_var.sum(dim=dim) == binary_var, name=convex_name) weighted = (lambda_var * stacked_bp).sum(dim=[SEGMENT_DIM, dim]) return model.add_constraints(target_expr == weighted, name=link_name)