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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] =?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/65] 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/65] =?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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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/65] 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": "iVBORw0KGgoAAAANSUhEUgAAA90AAAFUCAYAAAA57l+/AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/TGe4hAAAACXBIWXMAAA9hAAAPYQGoP6dpAABipklEQVR4nO3dB3hU1dbG8Teh9w6hN6kKqKAIglQFVKRdKyoiFxugYENUQFBB0Ssq1Qr4KaIoXQERKSodREGKgDTpgvQe8j1rDzNOQgIJZDKTzP/3PGM4Z85MTk7G7LP2XnvtiJiYmBgBAAAAAIBkF5n8bwkAAAAAAAi6AQAAAAAIIEa6AQAAAAAIEIJuAAAAAAAChKAbAAAAAIAAIegGAAAAACBACLoBAAAAAAgQgm4AAAAAAAKEoBsAAAAAgAAh6AYAAACC5KWXXlJERESqv/72M3Tu3DnYpwGEJIJuIIWNHDnSNUzeR+bMmVW+fHnXUO3atcsds2jRIvfcwIEDz3l9ixYt3HMjRow457kbbrhBRYsW9W3Xr19fV1xxRYB/IgAAcL52vkiRImrSpIneffddHTp0KCQv1rfffus6AAAkP4JuIEj69u2r//u//9PgwYNVu3ZtDRs2TLVq1dLRo0d19dVXK2vWrPrpp5/Oed28efOUPn16/fzzz7H2nzx5UosXL9b111+fgj8FAAA4Xztv7XuXLl3cvq5du6pKlSr67bfffMe9+OKLOnbsWEgE3X369An2aQBpUvpgnwAQrpo1a6YaNWq4f//3v/9Vvnz59NZbb2nixIm6++67VbNmzXMC67Vr1+rvv//WPffcc05AvnTpUh0/flx16tRRamCdC9axAABAWm/nTY8ePfTDDz/o1ltv1W233abVq1crS5YsriPdHgDSLka6gRDRsGFD93Xjxo3uqwXPlm6+fv163zEWhOfMmVMPPfSQLwD3f877uuSwf/9+devWTaVKlVKmTJlUrFgx3X///b7v6U2f27RpU6zXzZ492+23r3HT3K1jwFLgLdh+/vnn3Y1HmTJl4v3+Nurvf7NiPv30U1WvXt3dpOTNm1d33XWXtm7dmiw/LwAAKdHW9+zZU5s3b3ZtWkJzumfMmOHa89y5cyt79uyqUKGCazfjtrVffPGF2x8VFaVs2bK5YD5uu/jjjz/q9ttvV4kSJVx7Xrx4cde++4+uP/DAAxoyZIj7t39qvNeZM2f0zjvvuFF6S5cvUKCAmjZtqiVLlpzzM06YMMG1+fa9Lr/8ck2bNi0ZryCQOtGtBoSIDRs2uK824u0fPNuI9mWXXeYLrK+77jo3Cp4hQwaXam4NrPe5HDlyqFq1apd8LocPH1bdunVdL/yDDz7o0t0t2J40aZL++usv5c+fP8nvuXfvXtfrb4Hyvffeq0KFCrkA2gJ5S4u/5pprfMfazciCBQv0xhtv+Pa9+uqr7kbljjvucJkBe/bs0aBBg1wQ/8svv7gbEwAAQt19993nAuXvvvtOHTt2POf533//3XVKV61a1aWoW/BqHfBxs9+8baMFx927d9fu3bv19ttvq3Hjxlq+fLnroDZjx4512WWPPvqou8ewujHWflp7bs+Zhx9+WNu3b3fBvqXEx9WhQwfX2W7tuLXBp0+fdsG8tdX+HeR2zzJu3Dg99thj7p7E5rC3adNGW7Zs8d3fAGEpBkCKGjFiRIz9r/f999/H7NmzJ2br1q0xY8aMicmXL19MlixZYv766y933MGDB2PSpUsX06FDB99rK1SoENOnTx/372uvvTbmmWee8T1XoECBmBtvvDHW96pXr17M5ZdfnuRz7NWrlzvHcePGnfPcmTNnYv0cGzdujPX8rFmz3H776n8etm/48OGxjj1w4EBMpkyZYp566qlY+wcMGBATERERs3nzZre9adMmdy1effXVWMetWLEiJn369OfsBwAgWLzt4+LFixM8JleuXDFXXXWV+3fv3r3d8V4DBw5023aPkBBvW1u0aFF3v+D15Zdfuv3vvPOOb9/Ro0fPeX3//v1jtbOmU6dOsc7D64cffnD7H3/88QTvCYwdkzFjxpj169f79v36669u/6BBgxL8WYBwQHo5ECTWE23pWZbmZaO/lj42fvx4X/Vx6yG2Xm7v3G0babaUciu6ZqxgmrfX+48//nAjv8mVWv7111+7EfNWrVqd89zFLmtiPfXt27ePtc9S5a3X/Msvv7RW3rff0uVsRN9S4Yz1mltqm41y23XwPiydrly5cpo1a9ZFnRMAAMFgbX5CVcy9mVtW48XavvOxbDG7X/D6z3/+o8KFC7uiaF7eEW9z5MgR137avYS1u5Yplph7Amv7e/fufcF7Aru3KVu2rG/b7mOsrf/zzz8v+H2AtIygGwgSmztlaVwWMK5atco1SLaciD8Lor1zty2VPF26dC4YNdZg2hzpEydOJPt8bkt1T+6lxqwzIWPGjOfsv/POO938s/nz5/u+t/1ctt9r3bp17ubAAmzrqPB/WAq8pdQBAJBa2DQu/2DZn7V/1rFuadw2Fcs65q1zOr4A3NrFuEGwTUnzr7diqd02Z9tqoViwb21nvXr13HMHDhy44Llau2xLntnrL8TbWe4vT548+ueffy74WiAtY043ECTXXnvtOYXC4rIg2uZdWVBtQbcVMLEG0xt0W8Bt86FtNNwqn3oD8pSQ0Ih3dHR0vPv9e9r9NW/e3BVWsxsK+5nsa2RkpCv64mU3Gvb9pk6d6joe4vJeEwAAQp3NpbZg11uvJb72cu7cua5T/ptvvnGFyCwDzIqw2Tzw+NrBhFibfOONN2rfvn1u3nfFihVdwbVt27a5QPxCI+lJldC5+WezAeGIoBsIYf7F1Gwk2H8Nbut1LlmypAvI7XHVVVcl2xJclhq2cuXK8x5jPdfeKuf+rAhaUljjbwVjrJiLLZlmNxZWxM1+Pv/zsQa7dOnSKl++fJLeHwCAUOItVBY3u82fdT43atTIPaxt7Nevn1544QUXiFsKt38mmD9rK63omqV1mxUrVrgpaKNGjXKp6F6WaZfYznRrg6dPn+4C98SMdgM4F+nlQAizwNMCzZkzZ7plObzzub1s25bmsBT05Fyf2yqN/vrrr26OeUK91d45W9Yb79+j/v777yf5+1kqnVVN/fDDD9339U8tN61bt3a953369Dmnt9y2rTI6AAChztbpfvnll13b3rZt23iPseA2riuvvNJ9tQw3f5988kmsueFfffWVduzY4eql+I88+7ed9m9b/iu+TvD4OtPtnsBeY21wXIxgA4nDSDcQ4iyY9vaK+490e4Puzz//3HdcfKzA2iuvvHLO/vM1+M8884xruC3F25YMs6W97CbAlgwbPny4K7Jma29aOnuPHj18vd9jxoxxy4gk1c033+zmtj399NPuBsEaeH8W4NvPYN/L5qm1bNnSHW9rmlvHgK1bbq8FACBU2JSoNWvWuHZx165dLuC2EWbLUrP21Na7jo8tE2Yd2rfccos71uqWDB06VMWKFTunrbe21/ZZoVL7HrZkmKWte5cis3Rya0OtjbSUcitqZoXR4ptjbW29efzxx90ovLXHNp+8QYMGbpkzW/7LRtZtfW5LS7clw+y5zp07B+T6AWlKsMunA+EmMUuJ+Hvvvfd8y4LEtWzZMvecPXbt2nXO896luuJ7NGrU6Lzfd+/evTGdO3d239eWAClWrFhMu3btYv7++2/fMRs2bIhp3LixW/arUKFCMc8//3zMjBkz4l0y7EJLl7Vt29a9zt4vIV9//XVMnTp1YrJly+YeFStWdEucrF279rzvDQBASrfz3oe1oVFRUW5ZT1vKy3+Jr/iWDJs5c2ZMixYtYooUKeJea1/vvvvumD/++OOcJcM+//zzmB49esQULFjQLTt6yy23xFoGzKxatcq1rdmzZ4/Jnz9/TMeOHX1Ledm5ep0+fTqmS5cubglSW07M/5zsuTfeeMO1u3ZOdkyzZs1ili5d6jvGjrc2Oa6SJUu6+wcgnEXYf4Id+AMAAABInNmzZ7tRZquHYsuEAQhtzOkGAAAAACBACLoBAAAAAAgQgm4AAAAAAAKEoBsAAPiUKlXKrdcb99GpUyf3/PHjx92/8+XLp+zZs7vVBqxqMoCUU79+fbdcF/O5gdSBQmoAACDWMoPR0dG+7ZUrV+rGG2/UrFmz3I3+o48+qm+++UYjR45Urly53HJBkZGR+vnnn7mKAADEg6AbAAAkqGvXrpoyZYpbn/fgwYMqUKCARo8e7Rths3WIK1WqpPnz5+u6667jSgIAEEd6pUJnzpzR9u3blSNHDpfyBgBAamTpoYcOHVKRIkXcaHGoOXnypD799FM9+eSTrr1dunSpTp06pcaNG/uOqVixokqUKHHeoPvEiRPu4d+O79u3z6Wo044DANJ6O54qg24LuIsXLx7s0wAAIFls3bpVxYoVC7mrOWHCBO3fv18PPPCA2965c6cyZsyo3LlzxzquUKFC7rmE9O/fX3369An4+QIAEIrteJKD7rlz5+qNN95wvd07duzQ+PHj1bJlS9/zCfVYDxgwQM8884yvSMvmzZvPaZCfe+65RJ2DjXB7f7icOXMm9UcAACAkWLq2dSJ727VQ89FHH6lZs2auB/9S9OjRw42Wex04cMCNjtOOIxAs+8LuUe2eNCoqKtGv231sN7+QVKBgloJJOt46BG00snDhwm46DBCMdjzJQfeRI0dUrVo1Pfjgg2rduvU5z9sfOX9Tp05Vhw4dXHVTf3379lXHjh1920m54fAG9hZwE3QDAFK7UEyxts7x77//XuPGjfPtswDGUs5t9Nt/tNuql58vuMmUKZN7xEU7jkDwpnhaZ9Fff/2V6NdVGVWFX0gqsKLdiiQdb6OP27Ztc58L4gYEqx1PctBtPd72SEjcRnfixIlq0KCBypQpE2u/BdlJ6X0EAAApZ8SIESpYsKBuueUW377q1asrQ4YMmjlzpq8zfe3atdqyZYtq1arFrwcAgHgEtGqL9XzbsiI20h3Xa6+95gqoXHXVVS5d/fTp0wm+jxVfsaF7/wcAAAgMK3RmQXe7du2UPv2//fO2RJi16ZYqbkuI2VSz9u3bu4CbyuUAAAShkNqoUaPciHbcNPTHH39cV199tfLmzat58+a5uV6Wlv7WW2/F+z4UYAEAIOVYWrmNXttUsrgGDhzo0jRtpNs6xZs0aaKhQ4fy6wEAIBhB98cff6y2bdsqc+bMsfb7F1OpWrWqq4T68MMPu+A6vjlfcQuweCesX0h0dLRb2gQIZZaqmS5dumCfBgD43HTTTa7wUHysTR8yZIh7BBrteOijDQOAIAbdP/74o5vn9cUXX1zw2Jo1a7r08k2bNqlChQqJLsCSELtRsEqFVugFSA2sIJHVOAjFYkoAzjoTLW2eJx3eJWUvJJWsLUXSYRYItOOpC20YAAQp6LZlRqzgilU6v5Dly5e7VDUr2JIcvAG3vV/WrFkJZBDSN5ZHjx7V7t2eZUpsOQsAIWjVJGlad+ng9n/35SwiNX1dqnxbMM8sTaIdTx1owwAgQEH34cOHtX79et/2xo0bXdBs87NtzU1v+vfYsWP1v//975zXz58/XwsXLnQVzW2+t21369ZN9957r/LkyaPkSEXzBtxWqA0IdVmyZHFfLfC2zy2p5kAIBtxf3m8hRuz9B3d49t/xCYF3MqIdT11owwAgAEH3kiVLXMDs5Z1rbRVOR44c6f49ZswY1/t59913n/N6SxO351966SVXgKV06dIu6Pafs30pvHO4bYQbSC28n1f7/BJ0AyGWUm4j3HEDbsf2RUjTnpMq3kKqeTKhHU99aMMAIJmD7vr16ydYXMXroYceco/4WNXyBQsWKNCYG4vUhM8rEKJsDrd/Svk5YqSD2zzHla6bgieW9vF3MfXgdwUAQVynGwCAVM2KpiXncQAAIOwQdIcIyx6w7ACbG289xjZPPjlYGv+VV155weN69uwZKzvBMhq6du2qYJg9e7a7BoGuPp8SP+Pw4cPVvHnzgH4PAAEUcyZxx1k1cyCNeuCBB9SyZctgnwYApFoE3eebx7fxR2nFV56vth1A06ZNc3Pip0yZoh07duiKK65QSlaJfeedd/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": "iVBORw0KGgoAAAANSUhEUgAAA90AAAFUCAYAAAA57l+/AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/TGe4hAAAACXBIWXMAAA9hAAAPYQGoP6dpAACBoklEQVR4nO3dB3gUVRcG4C89gYRAgCQEQq+h995RmiACIghSBaUpVUQEBAuCikoRkB9RpCkKSFGQXkMH6SUQOiS0hJCQvv9z7rpxE5Kwgd1s+97nWcLMTjazM8meOXPvPddBo9FoQERERERERERG52j8lyQiIiIiIiIiJt1EREREREREJsSWbiIiIiIiIiITYdJNREREREREZCJMuomIiIiIiIhMhEk3ERERERERkYkw6SYiIiIiIiIyESbdRERERERERCbCpJuIiIiIiIjIRJh0E1mwjz76CA4ODrh79665d4WIiMiu9e7dG0WLFn3qdrLNSy+9lC37RETWgUk3URqhoaEYMmQISpcujRw5cqhHUFAQBg8ejOPHj9vN8frzzz9V0p+dbt68qX7msWPHsvXnEhGR9bh48SLeeustFC9eHO7u7siVKxfq16+Pb7/9Fo8fP4Y9++yzz7B69Wqbv14gsjZMuon0rFu3DhUqVMDPP/+MFi1a4Ouvv1ZBvHXr1iqoVKlSBVeuXLGLYybvd9KkSdmedMvPZNJNRETpWb9+PSpWrIhff/0V7dq1w8yZMzFlyhQULlwYo0ePxrvvvmvXB85cSXd2Xy8QWRtnc+8AkSXdOe/atSuKFCmCLVu2oECBAqmenzp1Kr777js4OvJelaFiY2Ph6upqF8csOjoaOXPmNPduEBHZdE80XZzeunVrqjgtvdFCQkJUUk7Px17iWXJyMuLj41VvCSJTs/0rYSIDTZs2TQWahQsXPpFwC2dnZ7zzzjsIDAxMWSfdzWWMl66Lm7+/P/r27Yt79+6l+t6oqCgMGzZMjfNyc3ODr68vXnjhBRw5csSgfYuIiFA/J3fu3PD29kafPn0QExPzxHaLFy9G9erV4eHhAR8fH3Vxcu3atVTb7Nq1C6+++qpqFZB9kfczfPjwVF3y5GfNnj1b/V/GlOsemdm+fbvaZvny5fjwww9RsGBB1TX/4cOHuH//PkaNGqVaJzw9PVVXQOk98M8//6T6/po1a6r/y/vT/cwff/wxZZv9+/ejVatW6hjIazdu3Bh79uwx+AaAdH+TYQNyruQcd+zYUd1s0d9/+arv8uXLT+yHHB95H/K9bdq0gZeXF7p3766GJcj69M5Nt27d1O9HUlJSyrq//voLDRs2VBc38hpt27bFqVOnDHo/RET2GKcfPXqEBQsWpBunS5YsmaqlOzExER9//DFKlCih4p3E4A8++ABxcXGpvu+PP/5Qn78BAQFqO9levk//8/pZ7N69G7Vq1VIxR64TFi1alG58l+sDicXys+U9yE1+SQj1ffnll6hXrx7y5s2rYrzE+t9++y3VNhKr5Drmp59+SomhEq8yk1E8M9b1gryPb775BuXLl1fHwc/PTw0NePDggUHH8OzZs+jSpQvy58+v3neZMmUwbty4p46z19XESXt8JE4vWbJE7Y+8p7Vr16rrJbnuSEuuX2Sf5fpFR353Jk6cqM6T7pi89957T/xOEaXFlm4iva7l8iFau3Ztg4/Jpk2bcOnSJfVhLQmVJEzff/+9+rpv376UD/y3335bBUf5sJfx4ZKUSzA+c+YMqlWr9tSfIwGnWLFiqgudJOr/+9//VOIugVnn008/xfjx49W2b775Ju7cuaO63TVq1AhHjx5VCbtYsWKFSgoHDhyogveBAwfUdtevX1fPCQmI0tVb3p90tc8KuVCR1m0JUhKE5P+nT59W3d0keMv7CAsLw7x581TSLM/JhU65cuUwefJkTJgwAQMGDFDJqJCLDCGtGpKoy4WGBDxpPZcbJM2aNVMXBnJhkxG5cJKiNtKDQW5EyEWZ3AiR93fy5El1gZVVcjHXsmVLNGjQQF0MyU0ACfxy8SEtLfJedeR4S2CXiwMnJye1To5rr1691GvIeZRt5syZo15PzpchxXqIiOyJfI5K8qqLC08jsVAS0M6dO2PkyJHqxq3EUYm9q1atStlObqpK4jlixAj1VeKNxCJJur744otn2ldpdZef269fP/VZ/8MPP6gYIDFMEj4hn/sSB2/cuKHiriS3e/fuxdixY3Hr1i2VrOrIULf27durhFhaZ+UGt8QZuXaRGwa6uCLvWeKhxFFhSHxLL54Z63pBnpfjK9dJ0nAhvRVmzZql4pzcNHdxcclwv6RhQ64FZBt5PxIX5eaA/B7INc+zkHMrQxPkeixfvnwoVaoUXnnlFaxcuVJdl8g1i45ct8h1jFw36G4gyDmQ6zfZH7luOXHihBqKeP78+Wzv1k9WRkNEmsjISI38OXTo0OGJo/HgwQPNnTt3Uh4xMTEpz+n/X2fZsmXqtXbu3JmyztvbWzN48OAsH+mJEyeq1+rbt2+q9a+88oomb968KcuXL1/WODk5aT799NNU2504cULj7Oycan16+zxlyhSNg4OD5sqVKynrZH+z8hGxbds2tX3x4sWf+BmxsbGapKSkVOtCQ0M1bm5umsmTJ6esO3jwoHqNhQsXpto2OTlZU6pUKU3Lli3V//XfS7FixTQvvPBCpvv2ww8/qNedPn36E8/pXk+3//I17X6m3adevXqpde+///4Tr1WwYEFNp06dUq3/9ddfU/1OREVFaXLnzq3p379/qu1u376tflfSricisne6OP3yyy8btP2xY8fU9m+++Waq9aNGjVLrt27dmmlcfOuttzQ5cuRQ8Uv/s79IkSJP/dmyTdrrgPDwcBXzRo4cmbLu448/1uTMmVNz/vz5VN8vsUVi+tWrVzPcx/j4eE2FChU0zZo1S7VeXk/201AZxbP0fmZWrxd27dql1i9ZsiTV+g0bNqS7Pq1GjRppvLy8Uv0soX8dkNE50V0/6ZNlR0dHzalTp1Kt37hxo3pu7dq1qda3adNGXdPo/Pzzz+r75X3pmzt3rvr+PXv2ZPp+yL6xeznRv12IhNzhTqtJkyaqW5PuoetGJaSrk373ZZnaq06dOmpZv+u4tDLLHXa5G/wspKVcn9z5ldZy3X7LHVq5Ayut3LIPuoe0vstd3G3btqW7z9INTbaTVgOJR3Ln+XnJHX39nyGkC5ZuXLe0Osu+y7GWbmKGdLGXwmoXLlzA66+/rr5X9/5k/5s3b46dO3c+0RVP3++//67uaA8dOvSJ557WbT4zcvc/7WtJy4MUlZEukDq//PKL6m4vrQhCWgSkS6F0Odc/X9IKLj0t9M8XERH9F6el+7Mh5HNYSOu1PmnxFvpjv/VjlvSCks9jibPSyivdm5+F9GrT9dgScv0gMU96x+lIa7FskydPnlSxQAq5SqyU2JbePkrX7MjISPW9hg5Ty2o8M8b1grw/GQ4mw+n035+09ss1QGaxTnrryfuXIXvSA8BYcVt6Fsi50Sc95uQaQWK1/jGWWP3aa6+lej/Sul22bNlU70e+XzB2U2bYvZxIL4jrJ0o60t1IgrB0ie7Ro0eq52SsslTslG5e4eHhqZ6TgKg/Dk2SURn7I8FGxk317NlTdZMzRNqAIwFaFxRkfLQkpBIEJcFOj373ratXr6puc2vWrHliTJX+PmcWCPXHuUng1L9ZId3H05KEWLrGSSE66Vqm//3SZe1p5P0JOYYZkX3XHZe0pDuaXOzIuHxjkdcqVKjQE+slQEuXQDm+cpNAfqfk4k+62OkuFHTvRxeo05JzSkRET34uSjw2hMw0Ijd7ZdiYPrkZLTfC9WcikSFhUotEuh7rkvusxEVD4raQGKUfdyUWSBdqScjTo39dId3IP/nkE3UTWn/8sCEJqHRHl+sVffIzdcOdMopnz3u9IO9PtpPhcE97f2npbk7IjDLGlN41irz/Tp06YenSperYSkOBNGYkJCSkSrrl/cjQBEPOF1FaTLqJAHUnVoqyyPjetHRjvKWgVlrSsizjr2SaEplOTJJPSTCl2Jd+y6tsJ3ekZQzZ33//rcaIyThe+VCXccpPowuMaWl7S2mTWgm8UpgrvW11SbEku3LHWYLvmDFj1N1aKeIl48lkrFlmrcU6UuxM/2JFxlfrz8+ZtpVbN4WJjDeXO9Yy5luKlsjFkBSPMeRn6raR4ybHOT3p9VLIiowuXDIqpKPfeq9PejrIuDMZMyZJt4w9k6Iz+oFb935k/JtcAKZlzJsDRES2knRL/Y/04nRmnpaUSq8jaf2U15e6IjIGWopnSQuyxElDYtSzxG0hry0xWQpxpUcKfwqpWyJjiaVGi9y8lusVuZkudU0kUXwauU5p2rRpqnVyA1xXOyS9eGaM6wXZRhJuKVyWnoySV1PG7vSuUYSM25ZGFrmO6tChg4rh8p4rV66c6v1IQdjp06en+xr6hXaJ0uKVHdG/pBCJFCiTQiGZFeXSkbu+UphLWrrlTrCOrhUzLQmSgwYNUg+5GyoF1KQQiCFJ99PIRYIEcrmDqwvS6ZGCH1LsQwrLSEu7jnShMjSQSfDUr1xqSGu9FJGTgC8VZ9Ne7EiXrqf9TF0hGLkokm53WSXfL9375a51RkVbdK3ksk/6nmVedrnJIi370mIi3dXkwkY37EC3P0IuRp7l/RAR2SMpiCnFSoODg1G3bt1Mt5VpxSRJkpgsXYJ1pNeafM7L80JmrJBhS3ITXJJa/aTU1CQWSG+op8UBGSIlNwI2btyoEmQdSbrTSi+OSuKYNs6nd8PX2NcL8v42b96M+vXrZ5jsZkR3bfG0mywSu9PG7WeJ3XLu5TpNYrYMBZNeD/pV0nXvR2ZdkWFtz9PFnewTx3QT/UvuNEvFTmmNlaCc2d1p/bvYadfrVxvV3W1N2w1Lki25Y2+sKSZk6ivZH7kBkHZ/ZFk3hVl6+yz/lwQxLd0cnWmDmQRPuUDQPQxJuuXnpt0vGRsld8wN+ZnSJV+CnVRVTW8IgHR5z4x0G5NxV1IxNS3dfskFmOyn/hg6Ia0KWSWt2nJu5WJlw4YNKgnXJ1Vi5QaC9ACQGwFZfT9ERPYapyVOSIXu9OK0DCXSxTMZxpVeTNa1UuoqfqcXF6U79rN89meVxAa5gSDJdFoSB6WquG4fJcnTb72V3nfpVcuW45M2hkpiqh+35fG0uamNcb0g70/2WXq4pSXvLb1kWb8VXBJhqfou3dz16e+TXBvINZZ009eRyu/61ekNIS39Um1eeqdJLzTZP/0earr3I9ct8+fPf+L7pTFCxr0TZYQt3UT/kvHQ0k1LilvJ+F+ZlkPuDsuHu9zxlufkQ1k37kmSJgkIMl5bEicplCVdx9PeHZfxZ/I98mEuryfdoOXO78GDB/HVV18Z5fhL0JGxXjLNiARi6Rol49RlXyTwyNQWMoWXdJWSbeX/EjjkPcgd9PTmy5REV8gUH5IkSgDWTZvxLK0T0m1PpgyRIixyB11azNMm7LJvMtZu7ty5av8lkEv3fmnBl14I0itAplqR15HjLe9BCpfI+5BAmRG5Sy/zo0pBHenJIF39JTjKeZCeBy+//LIaYiBF0GQ6FLm4kX2RMXTPMkZLejHIOEK5Sy7Jd9rALfsr04O98cYbals5rnKBIRcWUtxHbmykd4OAiMieyeeyxGL5TJXWa/lslzG/kiRLF2q5maubl1rirdQBkZZxXRdy+fyXm6ESI3XdrSUmSVIq20q8k89/SbrS3ig2BRmaJuOlJUbqphOT2CQxUnqISTyX3mByg0BuFsjQNRm2JHFJirpKnNFPNoW8hsQ22V5u7kv8zMpUqDrGuF6QYy71TGSaNhmL/uKLL6reZtL7QM6VJPBybZSRGTNmqFZniZNyHSPvRY6JxEl5PSE/R7q/y7Rf8vN1029Kr7+sFpmT3yu5BpBhc9KNXL+HhJCYLd3OpbitXHtIrJabClJsT9bLzZMaNWpk6WeSHTF3+XQiSxMSEqIZOHCgpmTJkhp3d3eNh4eHpmzZspq3335bTUGi7/r162r6Lpn+SaZ6evXVVzU3b95UU0fIdBUiLi5OM3r0aE3lypXV1BcynYf8/7vvvnvqvuimvJCpyvTJ9FWyXqaz0vf7779rGjRooH6GPGS/ZSqPc+fOpWxz+vRpTYsWLTSenp6afPnyqemp/vnnnyemxUpMTNQMHTpUkz9/fjU9yNM+LnRTbq1YseKJ52TKFZkmpUCBAup41q9fXxMcHKxp3Lixeuj7448/NEFBQWqqs7T7dPToUU3Hjh3VdGky9YpME9KlSxfNli1bnnosZeqTcePGqSnGXFxcNP7+/prOnTtrLl68mLKNHGeZ7kumicmTJ4+aMubkyZPpThkmxzcz8rPk++T3KLNjJtOgye+O/K6VKFFC07t3b82hQ4ee+n6IiOyVTLElsato0aIaV1dXFVslrsycOTPVFF8JCQmaSZMmpXzuBwYGasaOHZtqGyFTPdWpU0fFp4CAAM17772XMo2U/jSSWZkyrG3btk+sTy/myRSSsk8SK+S9SFyuV6+e5ssvv1TTguksWLBATZ0psU9iu8Sk9KbFOnv2rJpqS96LPPe06cMyi2fGul74/vvvNdWrV1f7JOeqYsWK6hjL9dLTSAzWXWdJnCxTpoxm/Pjxqbb5+++/1fRpcvzk+cWLF2c4ZVhm07fKVGTyOyLbffLJJ+luI+dk6tSpmvLly6tzIdcK8t7k90ymtSPKiIP8Y+7En4iIiIiIiMgWcUw3ERERERERkYkw6SYiIiIiIiIyESbdRERERERERCbCpJuIiIiIiIjIRJh0ExEREREREZkIk24iIiIiIiIiE3E21Qtbk+TkZNy8eRNeXl5wcHAw9+4QEZGdk9k8o6KiEBAQAEdH3h/XYbwmIiJrjNdMugGVcAcGBmbn+SEiInqqa9euoVChQjxS/2K8JiIia4zXTLoB1cKtO1i5cuXKvrNDRESUjocPH6qbwbr4RFqM10REZI3xmkk3kNKlXBJuJt1ERGQpOOQp/ePBeE1ERNYUrzlQjIiIiIiIiMhEmHQTERERERERmQiTbiIiIiIiIiIT4ZjuLExTEh8fb6rzQFbCxcUFTk5O5t4NIiLKRFJSEhISEniM7BjjNRFZErMm3Tt37sQXX3yBw4cP49atW1i1ahU6dOiQat6ziRMnYv78+YiIiED9+vUxZ84clCpVKmWb+/fvY+jQoVi7dq2aG61Tp0749ttv4enpabT9lGQ7NDRUJd5EuXPnhr+/PwscEZGSlKzBgdD7CI+Kha+XO2oV84GTY+YFVcg05Lrh9u3b6pqBiPGaiJ6QnARc2Qs8CgM8/YAi9QBHJ9tOuqOjo1G5cmX07dsXHTt2fOL5adOmYcaMGfjpp59QrFgxjB8/Hi1btsTp06fh7u6utunevbtK2Ddt2qTuavfp0wcDBgzA0qVLjRbA5fWldVPKwWc26TnZNvldiImJQXh4uFouUKCAuXeJiMxsw8lbmLT2NG5FxqasK+DtjontgtCqAj8jspsu4fb19UWOHDl4c9ROMV4TUbpOrwE2jAEe3vxvXa4AoNVUIKg9TMlBI59MFlJmXb+lW3YrICAAI0eOxKhRo9S6yMhI+Pn54ccff0TXrl1x5swZBAUF4eDBg6hRo4baZsOGDWjTpg2uX7+uvt/Q+dW8vb3V66edMkwS+ZCQEPVasg3RvXv3VOJdunRpdjUnsvOEe+DiI0gbRHVt3HN6VHvmxDuzuGTPMjsu0qX8/PnzKuHOmzev2faRLAfjNRGlSrh/7SlZJtKN2l0WPVPibWi8tthmW+nOLXesW7RokbJO3lDt2rURHBysluWrdB3SJdxCtpfW6P379xtlPySIC1dXV6O8Hlk/aT0RHC9IZN9dyqWFO7271rp18rxsR9lD95ms+4wmYrwmopQu5dLCnVnU3vC+djsTsdikWxJuIS3b+mRZ95x8lTva+pydneHj45OyTXri4uLUXQn9x/NOeE72g78LRCRjuPW7lKcXwuV52Y6yFz+jib8LRJSKjOHW71L+BA3w8IZ2O3tLuk1pypQpqtVc95Cx2kRERIa6ERFj0HZSXI2IiIjM6Kq2l/RTSXE1e0u6pTq0CAtL/eZlWfecfNUVtdJJTExUFc1126Rn7Nixqt+97nHt2jWTvAd79dFHH6FKlSrZ0pqxevVqk/8cIiKd6LhEfL/zIiavPWPQQZFq5kSWjDGbiGySRgOE7gIWdQC2fWrY90g1c3tLuqVauSTOW7ZsSVkn3cBlrHbdunXVsnyVKqUy5ZjO1q1b1dReMvY7I25ubmqgu/7D1GRcX/DFe/jj2A311dTj/Hr37q2SUt1Disq0atUKx48fh62QqvKtW7c2eHspwCc1AIiIsirycQJmbLmA+lO34rM/z+JhbAIymxXM4d8q5jJ9GFkhGdcnF2snftN+NeE4P8GY/STGbCJ65mT73AZgwYvATy8Bl7ZpU14Xj0y+yQHIVVA7fZgtThn26NEjVRlcv3jasWPH1JjswoULY9iwYfjkk0/UvNy6KcOkiriuwnm5cuVUItm/f3/MnTtXFVEZMmSIqmxuaOVyW55SRo7NwoUL1f9ljPuHH36Il156CVevXk13ezl+Li4usBaZ9WYgIjKGe4/isGB3KBYFX8GjuES1rli+nBjYpAQ8XJzwzrKjap3+bVRdLi6f8Zyv2wqZaUoZxmwioueQlAicXg3s/hoIO6ld5+QGVO0B1H8HuHX83+rlGUTtVp+bdL5us7Z0Hzp0CFWrVlUPMWLECPX/CRMmqOX33nsPQ4cOVfNu16xZUyXpMiWYbo5usWTJEpQtWxbNmzdXU4U1aNAA33//PSxtSpm0BXduR8aq9fK8qUiLviSm8pDu3u+//77qSn/nzh1cvnxZtYD/8ssvaNy4sTqmcizF//73P3VDQ9bJsf3uu+9Sve6YMWPUdFlSFbR48eLqZkhmlbwvXryotpMbIjIVnO7utXQNlxsq8nNk/vW03fznzJmDEiVKqMrxZcqUwc8//5xh93Ld+1m5ciWaNm2q9k3mgNdVut++fbuaw12GE+ha/6VLnZD3p9sPKdTXuXNnI50BIrJW8hk9ee1p1bL93faLKuEu4+eFGd2qYvOIxuhSIxDtKgeoacH8vVN3IZfl55kujCxgSpm0BXce3tKul+dNhDGbMZuInkFiHHD4R2BWDeD3ftqE29UTqPcOMOw48NJ0IE9R7U1TmRYsV5rYLDdVn3G6MKtp6W7SpIlKwjIiidHkyZPVIyPSKr506VJkF9nfxwmGdTOTLuQT15zKsDi93Ff5aM1p1C+Zz6DWEGlVedaqrHLDYvHixShZsqTqah4dHa3WSyL+1VdfqZsdusRbbnrMmjVLrTt69KjqSZAzZ0706tVLfY+Xl5dKnKU3wYkTJ9Tzsk5ukqQl3dkloe7Xr5/qtaATExODTz/9FIsWLVJJ9aBBg1QPhT179qjnZc72d999F998842aBm7dunUqaS5UqJBKqjMybtw4fPnllyqJlv9369ZN9aaoV6+eei15b+fOnVPbenp6qhs/77zzjkroZRupB7Br165nOsZEZP2u3Y/BnB0X8duh64hPSlbrKhXyxpCmJdGinB8c03xWS2L9QpC/qlIuRdNkDLd0KWcLt4WQa4wEw4reqS7kf0kcyyRqSwt48SZPbw1xySEXMc+0y4IxmzGbiJ4iPlqbbO+dCUT924jpkQeoMwio1V/7/7QksS7bVlulXIqmyRhu6VJuwhZui0i6rZEk3EETNhrltSSE334Yi4of/W3Q9qcnt0QOV8NPmSSqklgKSbILFCig1sk85jrShb9jx44pyxMnTlRJuG6ddOs/ffo05s2bl5J0Szd1naJFi2LUqFFYvnz5E0n33r17VXd2SX5HjhyZ6jlpGZfEXjf2/qefflKt6wcOHECtWrVU4ixj3CQZ1/WC2Ldvn1qfWdIt+9K2bVv1/0mTJqF8+fIq6ZYWe6lULzct9LulS1d7uaEg+yk3DooUKZLS84KI7EdI+CN8tz0Efxy7mVJzo1ZRHwxpVhINS+XL9IanJNh1S+TNxr0lg0nC/ZmxhpvJlDI3gc8NmPHkg5uAa84svTpjNmM2ERng8QPgwHxg3xzg8b/TcnoVAOoNBar1Aty0uU+GJMEu1hDZjUm3DZPkVLpoiwcPHqhu1FJ4TBJbnRo1aqT8XxJz6QourdLSeq1fEV4SVh3pkj5jxgy1rdyNl+fTFqOTZPaFF15QrdmS2Kcl86nLkAEdSYqly/mZM2dU0i1fZViBvvr16+Pbb7/N9D1XqlQp5f9yk0FIhXt5/fTIPkqiLd3fZTydPF555RXVPZ2IbN+pm5H4bttF/HnylmoUFZJkS8t27eJMpCn7MGYzZhNRJqJuA8GzgUM/APGPtOvyFAMaDAMqdwOc3WDJmHRnkXTxlhZnQ0h3w94LDz51ux/71DSowq387KyQFlzpTq4jY7UleZ4/fz7efPPNlG10JIEW8nza6u9OTtqfLWOku3fvrlqRpdu4vJ60ckvruL78+fOr7ufLli1D3759s6VCvNAvBKdrmZJq9hmR1u0jR46oMd9///236n4uY70PHjzISudENuzI1QeYvTUEW87+N+3kC0F+KtmuHMhZDmyGdPOWVmdDSHfDJQbU9Oj+29Mr3MrPzSLGbMZsIkrHg8vAnhnA0cVAUpx2nW95oOEIIKgD4GQd6ax17KUFkUTO0C7eDUvlV1XKpSBPeiPEHP4tuCPbZcf4P9l36Vr++PHjdJ+XImKSKF+6dEkl1umRLuPSMixdxnWuXLnyxHYeHh6qq5wUt5PkXBJaSXB1pHVcxlNLq7aQcdYy/Zt0MRfyVcZ367q0C1kOCgp65vcvY8eTkpLSbXWXcePykO710uIuU8/pd7snIusnNTmCL93D7G0h2BNyT62Tj962lQIwuGkJlPXPnpuDlI0kkTO0m3eJZtqCOlI0LaOoLc/Ldtkw/o8xmzGbyK6Fn9VWIj+xAtD8e/1eqCbQcBRQuuVz1c0wBybdJiSJtEwZI1XKHcwwpUxcXJyaKkzXvVzGUEtrdrt27TL8HmnBlsJi0oItXa3lNSQ5lu+XcdVSoEy6jkvrtnQPX79+vSp6ltFde3leurTLQyrP68aYS4u0VKaXbuqS9Epl8zp16qQk4aNHj0aXLl3U+GpJhteuXasqk2/evPmZj4eMP5f3L3O/S2Vz6UIuybXcZGjUqBHy5MmDP//8U7WMS7V0IrKdZHv7uTuYtS0Eh688UOucHR3wStWCauqv4vmfMv6L7IMk0jItmJpSxiHbp5RhzE6NMZvITt04DOyaDpxd99+64k2BhiOBog2sLtm2iCnD7IFUtjXXlDKS5Mq4ZnlId3HpMr1ixQpVNT4j0u1cuqHL/N4VK1ZU04lJpXIpqCbat2+P4cOHqyRZpiGTlm+ZMiwjkmT/9ddf6qJXCpzpqqZLwitTj73++utqrLZsJ2PFdWQudhm/LYXTpBiaFHKTfcps359GqpO//fbbeO2111T392nTpqlWbUnmmzVrplrXZb536RIvP5OIrFtysgZ/nbiFl2buRp8fD6qE29XZEW/UKYLto5vgi1crM+HOgp07d6qbttIjSn/KxvTIZ61sI7NG6JMZIqQnlQw5ks9fqSGiG9pkEcw4pQxjdmqM2UR2RKMBQncBizoA85v9l3CXfQnovw3ouVpb/MxKE27hoMlszi478fDhQ9WyK3M4px17HBsbi9DQUJV06s8PnlVSDZdTymhJEi/F1aQ7uTUy1u8EEZlGYlIy1h6/qQqkXQjXJnQ5XJ3QvXZh9G9YHL653K06LpmL3ECVYT7Vq1dXw2+kl5PcIE1L1kuvqTt37qheS/rFNKXX061bt9SNVJnFQqaClF5Thk79mR3xOmX6MDNMKWOJrDlmM14TWTiNBji/Adj1FXD93zpYDk5ApS5A/WGAb/qFkK0xXrN7eTbhlDJERKYVl5iElUduYM72i7h6Xzs3s5e7M3rXK4o+9YvBJ6crT8Fz0A0VysyNGzfU0KGNGzemTN+oI7NSSGuu9LrSzZwxc+ZMVftDejVJC7rFMNOUMkREdiEpETi9WtuNPPyUdp2TG1DtDaDeO0CeIrA1TLqJiMiqPY5PwvKDV/H9zku4FRmr1kmC3a9BMbxRtwhyuf83qwGZjtTDeOONN1TrdnpDdGT2C+lSrj9VpdTskAKf+/fvV9M1pjfOWR76LQpERGSlEuOAf5YBu78BHoRq17l6ATX7AnUGA15+sFVMuinb9e7dWz2IiJ7Ho7hE/Bx8BQt2X8LdR/FqnV8uN9WF/PXahQ2eaYKMY+rUqaowphTjTI8U9vT19U21Trb38fFJKfqZ1pQpU1RXdTIfxmwiem5xj4DDPwLBs4CoW/9OdeQD1BkE1HoT8Mhj8weZVyRERGRVImLisXDPZfy49zIiHyeodYXyeODtxiXQuXohuLvY59hbczp8+LAqfnnkyBFVQM1Yxo4dq2bO0G/pDgwMNNrrExGRCcXcBw7MB/bPAR5rZw+BVwBQbyhQvZfhUzraACbdRERkFe5ExeF/uy9hcfAVRMdr5+wsnj8nBjUpiZerBMDFiRNymMuuXbsQHh6OwoULp6xLSkrCyJEjVQXzy5cvw9/fX22jLzExUVU0l+fS4+bmph5ERGRFom4DwbOBQz8A8f/OUJGnGNBgOFC5K+Bsf5/rTLoNxCLvpD9ukYiyz82Ix2q89rIDVxGXqP37K+vvhSHNSqJ1hQKqUCWZl4zllvHZ+lq2bKnWS4VyUbduXVUBW1rFpQK62Lp1q/pMlWktjYWf0cTfBSIzeXAZ2DMDOLoYSPq3HodveaDhCCCoA+Bkv6mn/b5zA7m4uKiucjL1icztbMxuc2R9N17i4+PV74IU/nF1ZSVkIlO6ci9aVSL//ch1JCRpZ7esEpgbQ5qWRPNyvvw8zmYyn3ZISEjKskzPdezYMTUmW1q48+bN+0T8lBbsMmXKqOVy5cqhVatW6N+/P+bOnaumDBsyZAi6du1qlMrl8pksn803b95U8VqWGbPtE+M1UTYLPwPs/ho48Rug0fZEQ6FaQMORQOmWVj2/trEw6X4KJycnFCpUCNevX1fd44hy5MihLjDl4o6IjO9CWBRmbwvBmn9uIlmba6NOcR8MaVoK9UvmZSJlJocOHULTpk1TlnVjrXv16qXmcjbEkiVLVKLdvHlz9RnaqVMnzJgxwyj7J68nc3TLPOCSeBMxXhOZ2I3D2mm/zq77b12JZtpku0h9Jtt6HDTsN23QpOYyNk3uypN9k5swUm2XrSdExnfyRiRmbQ3BhlP/VbJuUia/atmuUdTHrg65IXHJHhlyXOSyRsaKS9wm+8V4TWQiGg1weRew6yvg0vb/1pdrBzQYARSsZleH/qGB8Zot3Vn48JYHEREZ1+Er9zFzawi2n7uTsq5VeX8MbloSFQt583BTlshNUenaLg8iIjISqWl0YaM22b5+8N8PXCegUheg/jDAtywPdSaYdBMRUbaT1si9F+9h5tYL2Hfpvlon9dDaVw7AoKYlUdrPi2eFiIjI3JISgVOrtGO2w09p1zm5AdXeAOq9A+QpYu49tApMuomIKFuT7a1nw1XL9rFrEWqdi5MDOlUrpObZLprPfubsJCIisliJccCxpcCeb7RVyYWrF1CzH1BnEODlZ+49tCpMuomIyOSSkjXYcPI2Zm0LwZlbD9U6N2dHdKtVGAMaFUdAbg+eBSIiInOLewQc/hEIngVE3dKu8/DRJtq13gQ88ph7D60Sk24iIjKZhKRkrDl2E7O3h+DSnWi1LqerE3rULYI3GxRHfi83Hn0iIiJzi7kPHJgP7J8DPH6gXecVANQbClTvBbiyJ9rzYNJNRERGF5eYhN8OX8fcHRdx7f5jtS6XuzP61C+GPvWLIncOznNPRERkdlG3ta3ahxYC8Y+063yKa4ujVe4KOPPmuDEw6SYiIqOJiU/EsgPX8P3Oiwh7GKfW5c3pijcbFkePOoXh5c6K0kRERGZ3PxTYOwM4ugRI0sZr+FUAGo4AgjoAjpy1yZiYdBMR0XOLik3AouArWLA7FPej49U6/1zueKtxcXStWRgergzeREREZhd+RluJ/MRvgCZJuy6wNtBwJFDqRZl30dx7aJOYdBMR0TN7EB2PhXtC8ePey3gYm6jWBfp4YGDjkuhUvSDcnJlsExERmd31w8Du6cDZdf+tK9Fcm2wXqcdk28SYdBMRUZaFR8Xif7tCsXjfFcTEa++Ul/T1xOCmJdCuUgCcnRx5VImIiMxJowFCdwK7vgJCd/y70gEo107bjTygKs9PNmHSTUREBrsR8RjzdlzE8oPXEJ+YrNYFFciFoc1KomV5fzg6slsaERGRWSUnA+c3aJPtG4e06xycgEqvAQ2GAfnL8ARlMybdRET0VKF3ozFnewhWHrmBxGSNWletcG4MaVYSTcv4woFjwIiIiMwrKRE4tUrbjTz8tHadsztQ9Q3t1F95ivAMmQmTbiIiytC521GYvS0E647fxL+5NuqVyKuS7brF8zLZJiIiMrfEOODYUmDPN8CDy9p1rl5ArTeBOoMAT19z76HdY9JNRERPOH49ArO2huDv02Ep65qV9cXgpiVRvUgeHjEiIiJzi3sEHF4I7J0FPLqtXZcjL1BnIFCzP+CR29x7SP9i0k1ERCkOhN7HrG0h2Hn+jlqWXuOtK/hjUJOSqFDQm0eKiIjI3GLuAwe+B/bPBR4/0K7zCgDqvwNU6wm45jT3HlIaTLqJiOycRqPB7pC7mLk1RCXdwsnRAS9XDsCgpiVQ0tfL3LtIREREUbeB4FnAoYVA/CPt8fApDjQYDlTqCji78hhZKCbdRER2KjlZgy1nwzFr6wX8cz1SrXNxckDn6oEY2LgECufNYe5dJCIiovuhwN4ZwNHFQFK89nj4VQQaDgeCOgCOTjxGFo5JNxGRnUlK1mD9iVv4blsIzt6OUuvcXRzRrVZhDGhUHAW8Pcy9i0RERBR2Gtj9NXDyd0CTpD0egbWBhqOAUi9ox4CRVWDSTURkJxKSkrH66A3M2X4Rl+5Gq3Webs54o24R9GtQDPk83cy9i0RERHT9sHaO7XPr/zsWJZoDDUcCReox2bZCTLqJiGxcbEISVhy+jrnbL+JGxGO1LncOF/SpVwy96xWFdw4Xc+8iERGRfdNogNCd2mQ7dMe/Kx2Acu2AhiOAgKpm3kF6Ho6wYElJSRg/fjyKFSsGDw8PlChRAh9//LEq+qMj/58wYQIKFCigtmnRogUuXLhg1v0mIrIE0XGJmL/zEhpN24bxq0+qhFtas8e2LovdY5rh3RalmHCTwXbu3Il27dohICBAzc++evXqlOcSEhIwZswYVKxYETlz5lTb9OzZEzdv3kz1Gvfv30f37t2RK1cu5M6dG/369cOjR/8WAyIiskfJycDZ9cD/WgCL2msTbkdnoEp3YPAB4LWfmXDbAItu6Z46dSrmzJmDn376CeXLl8ehQ4fQp08feHt745133lHbTJs2DTNmzFDbSHIuSXrLli1x+vRpuLu7m/stEBFlu8jHCfg5+DIW7A7Fg5gEta6AtzveblwCr9UMhLsLC65Q1kVHR6Ny5cro27cvOnbsmOq5mJgYHDlyRMVg2ebBgwd499130b59exW7dSThvnXrFjZt2qQSdYnpAwYMwNKlS3lKiMi+JCUCp1YCu6YDd85o1zm7a6f8qjcUyF3Y3HtIRuSg0W82tjAvvfQS/Pz8sGDBgpR1nTp1Ui3aixcvVq3ccjd95MiRGDVqlHo+MjJSfc+PP/6Irl27GvRzHj58qBJ5+V65+05EZI3uR8fjh92h+GnvZUTFJap1RfLmwKAmJfBK1UJwdbbozk1kRXFJWrpXrVqFDh06ZLjNwYMHUatWLVy5cgWFCxfGmTNnEBQUpNbXqFFDbbNhwwa0adMG169fV/Hc2o8LEdFTJcQC/ywF9nwLPLisXefqBdR6E6gzCPD05UG0IobGJYtu6a5Xrx6+//57nD9/HqVLl8Y///yD3bt3Y/r06er50NBQ3L59W3Up15E3Xbt2bQQHB2eYdMfFxamH/sEiIrJWYQ9jVTfyJfuv4nGCtrppaT9PDG5aEm0rFoCzE5Ntyn5yASLJuXQjFxKX5f+6hFtI/HZ0dMT+/fvxyiuvPPEajNdEZDPiHgGHFwJ7ZwGPbmvX5cgL1BkI1OwPeGg/K8k2WXTS/f7776uEuGzZsnByclJjvD/99FPVPU1Iwi2kZVufLOueS8+UKVMwadIkE+89EZFpXbsfg3k7L+LXg9cRn5Ss1lUs6K2S7ReD/ODoyKlEyDxiY2PVGO9u3bql3PmXuOzrm7oFx9nZGT4+PhnGbMZrIrJ6MfeB/fOA/XOB2AjtulwFgXrvANXeAFxzmnsPyd6T7l9//RVLlixRY71kTPexY8cwbNgw1QWtV69ez/y6Y8eOxYgRI1KWJbEPDAw00l4TEZnWxTuP1LRfMv1XYrJ2hFCNInkwpFlJNC6dX7UuEpmLjNXu0qWLGgImdVmeB+M1EVmth7eA4FnAoYVAgnaaTviUABoMByq9Bji7mnsPKRtZdNI9evRo1dqt6yYuVVFlbJjc+Zak29/fX60PCwtT1ct1ZLlKlSoZvq6bm5t6EBFZkzO3HmL2thCsP3FLzSwiGpbKp1q2axfzYbJNFpNwS6zeunVrqvFtErPDw8NTbZ+YmKgqmuvieVqM10Rkde6HasdrH1sCJMVr1/lV1E77FfQy4MhipvbIopNuqYYqY730STfzZCmtD6hq5RKot2zZkpJkS6u1jA0bOHCgWfaZiMjYjl2LwKytIdh8JixlXYtyfqplu0ogx4CRZSXcMm3ntm3bkDdv3lTP161bFxERETh8+DCqV6+u1kliLjFdarEQEVm1sNPA7q+Bk78BGm2ugsA6QKNRQMkWUoHS3HtIZmTRSbfMBypjuKXqqXQvP3r0qCqiJtOVCOlCKd3NP/nkE5QqVSplyjDpfp5ZRVUiIksnXXP3h95XLdu7LtxV6yRet6lYAIOblERQACs3U/aS+bRDQkJSlqWYqQz7kjHZ0tusc+fOatqwdevWqRosunHa8ryrqyvKlSuHVq1aoX///pg7d65K0ocMGaJ6sxlSuZyIyCJdP6Sd9uvc+v/WSZLdcCRQpJ4594wsiEVPGRYVFaWSaJmWRLqkSVCWoiwTJkxQAVzI7k+cOFFVOZc76A0aNMB3332nqp0bilOQEJGlkM+0HefvqGT74OUHap2TowNeqVoQA5uUQIn8nubeRcoGlhiXtm/fjqZNmz6xXoZ7ffTRR+rGd3qk1btJkybq/9KVXBLttWvXqp5sMg3ojBkz4OnpabXHhYjskKRPoTuAXV8BoTv/XekABLUHGowAAjIe5kq2xdC4ZNFJd3ZhECcic0tO1uDv02Eq2T5xI1Ktc3VyRJeahfBWoxII9Mlh7l2kbMS4xONCRBZIhrie/0ubbN84rF3n6KwtjFZ/GJDf8EY/sg02MU83EZGtS0xKVoXRJNk+H/ZIrfNwccLrtQtjQKPi8Mvlbu5dJCIism9JicCpldpu5HfOaNc5uwPVegH1hgK5OQsSZY5JNxGRGcQnJmPV0etq6q/L92LUOi83Z/SsVwR96xdDXk/OsEBERGRWCbHAP0uB3d8AEVe069xyATXfBOoMBDx9eYLIIEy6iYiyUWxCEn45eA3zdlzEzchYtS5PDheVaPesVxTeHi48H0REROYUF6WdX1vm2X7078whOfICdQZpE24PzhxCWcOkm4goGzyKS8SSfVcwf1co7j6KU+vye7lhQMPiqit5Tjd+HBMREZlVzH1g/zxg/1wgNkK7LlchbRfyaj0BV9ZXoWfDqzwiIhOKjEnAj3svY+HeUETEJKh1BXN74O0mJfBq9UJwd3Hi8SciIjKnh7e0rdrSup0QrV2XtyTQYDhQsQvgrJ01iehZMekmIjIBac1esDsUPwdfUa3coli+nGraL5n+y8XJkcediIjInO6HAnu+BY4tAZLitev8K2rn2C7XHnDkjXEyDibdRERGdDsyFvN2XsSyA1cRm5Cs1pX198KgpiXRtmIBNec2ERERmVHYKWD318DJ3wGNNlajcF1tsl2yBeDAWE3GxaSbiMgIrt6LwZwdF/H74euIT9IG8MqFvDGkWSk0L+sLRybbRERE5nXtILB7OnDuz//WSZItyXaReubcM7JxTLqJiJ5DSHgUvtt2EX/8cxNJyRq1rlYxHwxpWhINS+WDA++WExERmY9GA4TuAHZ9BYTu/HelAxD0snbMdkAVnh0yOSbdRETP4NTNSMzeFoK/Tt5W8Vw0Kp1fJduSdBMREZEZJSdrW7SlZfvGYe06R2egUlegwTAgXymeHso2TLqJiLLg8JUHKtneejY8Zd2LQX4Y0qwkKhXivJ1ERERmlZSoHastyfads9p1zh7aKb9k6q/cgTxBlO2YdBMRPYVGo0HwpXuYtTUEey/eU+tkiPZLlQIwqGkJlPXPxWNIRERkTgmx2irkUo084op2nVsuoFZ/oPZAwDM/zw+ZDZNuIqJMku3t5+5g1rYQ1cKtPjQdHdCxWkEMbFJSTQFGREREZhQXpZ1fW+bZfhSmXZcjH1B3EFDzTcDdm6eHzI5JNxFRGsnJGmw8dVsl26duPlTrXJ0d0bVmIAY0Ko5CeXLwmBEREZlTzH1g/1xg/zwgNkK7LlchoP47QNU3AFfGarIcTLqJiP6VmJSMtcdvYva2iwgJf6TW5XB1Qo86RfBmg2LwzeXOY0VERGROD28CwbO1rdsJ0dp1eUtqK5FX7AI4u/L8kMVh0k1Edi8uMQkrj9zAnO0XcfV+jDoeXu7O6FOvKPrUL4Y8ORnAiYiIzOr+Je147WNLgaR47Tr/Sto5tsu1AxydeILIYjHpJiK79Tg+CcsPXsW8HZdw+2GsWueT0xX9GhTDG3WLIJe7i7l3kYiIyL6FnQJ2f62tSK5J1q4rXE+bbJdsDjg4mHsPiZ6KSTcR2Z2o2AQs3ncV/9t1CfeitXfL/XK5YUCjEuhWKxA5XPnRSEREZFbXDgK7vgLO//XfupIvAA1HAEXqmXPPiLKMV5ZEZDciYuKxcM9l/Lj3MiIfJ6h1hfJ4YGCTEuhcvRDcnNk1jYiIyGw0GuDSdm2yfXnXvysdgPIdtGO2C1TmySGr5GjuHSAiMrU7UXGY8tcZ1P98K77dckEl3MXz58RXr1bGtlFN0L12ESbcRE+xc+dOtGvXDgEBAXBwcMDq1aufmGJvwoQJKFCgADw8PNCiRQtcuHAh1Tb3799H9+7dkStXLuTOnRv9+vXDo0faooVEZOOSk4DQXcCJ37RfZTnluWTgzDpgfjPg5w7ahNvRGajaAxhyEHj1RybcZNXY0k1ENutmxGN8v/MSlh24irhE7TiwcgVyYUjTkmhVwR9OjhwHRmSo6OhoVK5cGX379kXHjh2feH7atGmYMWMGfvrpJxQrVgzjx49Hy5Ytcfr0abi7ayv/S8J969YtbNq0CQkJCejTpw8GDBiApUuX8kQQ2bLTa4ANY7SVx3VyBQAvfqYtirZ7OnDnrHa9swdQvRdQdwiQO9Bsu0xkTA4auTVt5x4+fAhvb29ERkaqu+9EZN2u3ItWlch/P3IdCUnaj7gqgbkxtFlJNCvrq1rpiCyZpccl+RtatWoVOnTooJblUkJawEeOHIlRo0apdbLvfn5++PHHH9G1a1ecOXMGQUFBOHjwIGrUqKG22bBhA9q0aYPr16+r77f240JEGSTcv/aUT4rMD49bLqBWf6D2QMAzPw8lWQVD4xJbuonIZlwIi8LsbSFY889NJP8b2+sU98HQZqVQr0ReJttEJhIaGorbt2+rLuU6chFSu3ZtBAcHq6RbvkqXcl3CLWR7R0dH7N+/H6+88grPD5GtkS7k0sKdWcLt4Ag0HadNuN29s3PviLKNQUm3j49Plu+AHzlyBEWKFHnW/SIiMtjJG5GYtTUEG07dTlnXpEx+1Y28RtGsfX4RWTtzxGxJuIW0bOuTZd1z8tXX1zfV887Ozmp/ddukFRcXpx76LQpEZEWu7E3dpTw9Mg1YYG0m3GTTDEq6IyIi8M0336i71k8jXcwGDRqEpCS94ghERCZw6PJ9zNoWgu3n7qSsa1XeH4OblkTFQrxbTvbJlmL2lClTMGnSJHPvBhE9q0dhxt2OyEoZ3L1cuoalvUOdkaFDhz7PPhERZZok7L14DzO3XsC+S/fVOqmH1r5yAAY1LYnSfl48emT3sjtm+/v7q69hYWGqermOLFepUiVlm/Dw8FTfl5iYqCqa674/rbFjx2LEiBGpWroDA1lYicgqPH4AnFxp2LaeqXvJENll0p0sZfyzICoq6ln3h4gow2R769lwzNwagmPXItQ6FycHdKpWCG83LoGi+XLyyBGZKWZLtXJJnLds2ZKSZEuCLGO1Bw4cqJbr1q2rWuEPHz6M6tWrq3Vbt25V+ytjv9Pj5uamHkRkReQz6Phy4O/xQMzdp2zsoK1iXqReNu0ckYW3dMuYKgY+IspuScka/HXyFmZvu4gzt7TjOd2cHdGtVmEMaFQcAbk9eFKIsiFmy3zaISEhqYqnHTt2TI3JLly4MIYNG4ZPPvkEpUqVSpkyTCqS6yqclytXDq1atUL//v0xd+5cNWXYkCFDVKu8IZXLicgK3D4JrB8JXNunXc5XBqjQEdj++b8b6BdU+3cmkVafA45O2b6rRBaZdMvYMLlL3bRpU/WoU6cOXFxcTLt3RGS3EpKSsebYTczeHoJLd6LVupyuTuhRtwjebFAc+b3Y+kWUnTH70KFD6rV0dN2+e/XqpaYFe++999Rc3jLvtrRoN2jQQE0JppujWyxZskQl2s2bN1dVyzt16qTm9iYiKxcbCWybAhz4HtAkAS45gSZjtNN/ObsCvkHpz9MtCXdQe3PuOZFlzdMtAXX79u3qcfXqVXh4eKBevXpo1qyZCsI1a9aEk5N13qXivJ9EliMuMQm/Hb6u5tm+/uCxWpfL3Rl96hdDn/pFkTuHq7l3kcji45KtxmzGayILI2nEiRXA3x/+VwwtqAPQ8jPAu+CT04dJNXPZTsZwS5dytnCTlTM0LhmcdOu7dOmSCuQ7duxQX69fv46cOXOiYcOGWL9+PawNgziR+cXEJ2LZgWv4fudFhD3UThGUz9MV/RoUR486heHlzp41ZD+MGZdsKWYzXhNZkLDTwJ+jgCt7tMt5SwJtvgBKNDP3nhHZRtKtT8Z0LViwADNnzlTjvSx12pHMMIgTmfHvLzYBPwdfwYLdobgfHa/WFfB2V+O1u9YsDA9X62uNI7LUuGTtMZvxmsg0Pv74Y0ycOFFN0Sf1GDIVF6Udo71vjrYrubMH0Hg0UHcI4MyhX2RfHhoYrw0e060j3dS2bduW0m3t7t27aqzYqFGj0Lhx4+fdbyKyEw+i47FwTygW7r2MqNhEta6wTw4MbFICHasVhJszk22i58WYTUSGJNwTJkxQ/9d9TTfxlna6UyuBjeOAqFvadWVf0o7Lzs2p/IgyY3DS3bdvX5Vky3ya9evXV93SpFiKjAtzds5y7k5Edio8Khb/2xWKxfuuICZe28pW0tcTg5uWQLtKAXB2cjT3LhJZPcZsIspqwq2TbuJ955y2K3noTu1ynmLaruSlXuCBJjKAc1aKssiUIOPGjVNVR6tWrQoHh39L/ROR3ZOpvQ6E3ldJta+XO2oV84GT43+fETciHmPejotYfvAa4hO18wiXD8iFIU1LomV5fzjqbUtEz4cxm4ieJeF+IvF+bziw8wsgeDaQnAA4uwMNRwL13gFc/puZgIiMlHSfOXMmpVv5V199peYAlelApEt5kyZNUK1aNTX9h7HduHEDY8aMwV9//YWYmBiULFkSCxcuRI0aNdTzMiRdxqDMnz9fTVEirfBz5sxR84QSUfbYcPIWJq09jVuRsSnrZFz2xHZBKOOfC3O2h2DlkRtITNaWkKhWODeGNiuFJmXy8+YdkQmYK2YTkfUn3Drq+b0zML62tt4KyrQBWk0B8hTNnp0ksiHPXEjt9OnTqhKqBPWdO3ciNjZWBfR169YZbecePHigWtRlepOBAwcif/78uHDhAkqUKKEeYurUqZgyZQp++uknFCtWTHWFOXHihNo//blBM8PCLETPl3APXHwEGX2QSPu17rn6JfNicNOSqFs8L5NtomyMS9kRs7MD4zVR9iTc+ia39sX4r38CyrTi4SfKrkJqOkFBQcibNy/y5MmjHsuXL1et0cYkCXVgYKBq2daRxFpH7hd88803+PDDD/Hyyy+rdYsWLYKfnx9Wr16Nrl27GnV/iOjJLuXSwp3ZnTt5rlmZ/BjSvBSqFc7DQ0hkBtkRs4nI9hJuMeGvcKDuQYwfz6Sb6FllKekODw9XXdV0XdbOnz8PV1dX1KpVC8OHD1ct0sa0Zs0atGzZEq+++qq6Q1+wYEEMGjQI/fv3T5n65Pbt22jRokXK98idhtq1ayM4ODjDpFu62clD/w4FEWWdjOHW71Kekf6NSjDhJspm2R2zicj2Em6dTKuaE5Hxku5y5cqpgC2VyqVieefOndW4MBlDbWg37qy6dOmSGp89YsQIfPDBBzh48CDeeecdddHQq1cvlXALadnWJ8u659Ij3dFlHkIiej5SNM2Y2xGRcZgjZhORZZMaSM/7/Uy6iUycdHfo0EHdFZcxYDly5EB2SE5OVgXTPvvsM7Us47tPnjyJuXPnqqT7WY0dO1Yl8vot3dKNnYiyRje/9tNINXMiyj7miNlEZNmkwelZW7p1309EJk66pXU4uxUoUECNQ0t79/73339X//f391dfw8LC1LY6slylSpUMX9fNzU09iOjZPI5Pwjebz+P7nZcy3U6KqPl7a6cPI6LsY46YTUSWTddK/SyJ9+TJk9nKTZQdSbf8sRniee6gpSXd4M6dO5dqnXSXK1KkSEpRNUm8t2zZkpJkS6v1/v37VbVzIjK+fZfu4f3fj+PyvRi1XKNIHhy68iBVlXKhm3Vbpg3Tn6+biEzPHDGbiCzcg8sYX+IU0MQNE7b/V9voaZhwE2XjlGEyn2dAQAB8fX1V1fB0X8zBAUeOHIGxyBjuevXqqe4sXbp0wYEDB1QRte+//x7du3dPqXD++eefp5oy7Pjx45wyjMjIomIT8PlfZ7Fk/1W17JfLDZ92qIgWQX6ZztPdqsJ/vVCIKHumxjJHzM4OnDKM6BkkxKr5trHrKyAxFnB0xscXK2HCou1P/VYm3ETZPGVY69atsXXrVjXGum/fvnjppZdUUDclKf6yatUqNQZb/uglqZYpwnQJt3jvvfcQHR2NAQMGICIiQo1f27BhAwvFEBnRtnPhGLfyBG7+m1R3qxWI91uXg7eHi1qWxPqFIH9VzVyKpskYbulSzhZuIvMwR8wmIgt0YTPw12jg/r/DwYo2BNp+hfH5ywAlM69mzoSbyAwt3eLmzZuqRfnHH39UWX3Pnj1VMC9TpgysGe+cE6XvQXQ8Pl53GiuP3lDLgT4emNqxEuqVzMdDRmThcckWYzbjNZGBIq4BG8cCZ9Zqlz39gZafAhU6STeXp04jxoSbyLhxKUtJt76dO3di4cKFqqhZxYoVsXnzZnh4eMAaMYgTpSYfC3+euI2Ja07i7qN4FZ/71CuGUS1LI4erwR1kiMhC4pKtxGzGa6KnSIwHgmcCO74AEh8DDk5AnYFAk/cBN690vyVt4s2Em8iM3cvT6/p9+fJlNXb66NGjSEhIsMoATkSphT+Mxfg/TmLjqTC1XNLXE9M6V0K1wnl4qIisFGM2kR24uA34czRw74J2uUh9oM2XgF/qmYAyqmou83BLHSXOxU1kfFlu6Q4ODsYPP/yAX3/9FaVLl0afPn3w+uuvI3fu3LBWvHNOpG3dXnH4Oj5ZdxoPYxPh7OiAQU1KYHCzknBzduIhIrLCuGRrMZvxmigdkTeAjR8Ap1drl3P6Ai9+AlTqkqorORFZQUv3tGnT1Liwu3fvqkJmu3btQqVKlYy1v0RkRtfux+CDVSew68JdtVyxoDemdqqEoIDn79ZKRNmPMZvITrqS758DbJ8KJEQDDo5ArbeApmMBd29z7x0RPeuUYYULF1YVUF1dXTPcbvr06bA2vHNO9io5WYOf913B1A1nEROfBFdnR4x4oTTebFAMzk6sdExkzVOGZXfMTkpKwkcffYTFixfj9u3basqy3r1748MPP1TTkwm55JAurPPnz1czjtSvXx9z5sxBqVKlDPoZjNdE/wrdCawfBdw9p10OrK2qksO/Ig8RkTW3dDdq1EgFzVOnTmW4jS6oEpHlu3jnEcb8dhyHrjxQyzWL5lGt28Xze5p714joOZkjZk+dOlUl0FIxvXz58jh06JDqzi4XI++8805KC/yMGTPUNjINqIwdbdmypaoP4+7ubtT9IbJJD28Bf38InPxNu5wjH/Dix0ClrnK3zdx7R0TGrl5uS3jnnOxJYlIyvt91Cd9svoD4xGTkdHXCmNZl0aN2ETg68sYZkSWwxrgkrep+fn5YsGBByrpOnTqpIqvS+i2XG9L6PXLkSIwaNUo9L+9PvkeGr3Xt2tUmjwuRUSQlAAe+B7ZNAeKjtF3Ja/QDmo0DPFjolMhcDI1LvCVGZEdO3YxEh+/2YNqGcyrhblQ6PzYOb4SedYsy4Sai51KvXj1s2bIF58+fV8v//PMPdu/ejdatW6vl0NBQ1e28RYsWKd8jFyq1a9dWBd+IKANX9gLzGmuLpUnCXbAG0H8b0PZLJtxEVsKgpHvEiBGIjo42+EXHjh2L+/fvP89+EZERxSUm4cuN5/DyrD04eeMhvD1c8OWrlfFTn5oolCcHjzWRDTFXzH7//fdVa3XZsmXh4uKCqlWrYtiwYar4qpCEW0jLtj5Z1j2XVlxcnGpF0H8Q2Y2oMGDlW8DC1kD4KcDDB2g/E+i3CQioYu69IyJjJ93ffvstYmJiDH7R2bNnqwIpRGR+h688QNsZuzFrWwgSkzVoVd4fm0Y0QufqhViHgcgGmStmy7RkS5YswdKlS3HkyBE1bvvLL79UX5/VlClTVGu47hEYGPjc+0lk8ZISgf3zgFk1gOPLZTQoUL0PMPQwUK0nx24TWSGDCqnJOCyZ39PQoitZucNORKYRE5+ILzaew497L0MqN+TzdMPHL5dH64oFeMiJbJi5Yvbo0aNTWrtFxYoVceXKFZU49+rVC/7+/mp9WFgYChT473NIlqtUqZJhK7y03OtISzcTb7JpV/cD60cCYSe0ywFVtVXJC1Y3954RkamT7oULF2b5hdN2HyOi7LP7wl28v/I4rj94rJY7ViuICS8FIXeOjKcOIiLbYK6YLa3rMlWZPicnJyQnJ6v/S7VySbxl3LcuyZYkev/+/Rg4cGC6r+nm5qYeRDbv0R1g80fAscXaZffcQIuJQLVegKOTufeOiLIj6ZY71ERk+SIfJ+Cz9Wfwy6Frarlgbg98+koFNCnja+5dI6JsYq6Y3a5dO3z66adqfnCZMuzo0aNqHvC+ffuq56XlXcZ4f/LJJ2pebt2UYVLRvEOHDmbZZyKzS04CDi8EtkwGYiO166q+AbT4CMiZz9x7R0RGYvA83URk2TadDsOHq08g7GGcWu5Ztwjea1UWnm78Myci05s5c6ZKogcNGoTw8HCVTL/11luYMGFCyjbvvfee6s4+YMAANY68QYMG2LBhA+foJvt0/RCwfgRw6x/tsn8lbVfywFrm3jMiMjLO0815P8nK3XsUh4lrTmHd8VtquVi+nJjaqRJqFfMx964R0TPifNQ8LmTDou8BWyYBRxZJFQbAzRtoPh6o0ZddyYlsNF6zCYzIioslrfnnJj5acwoPYhLg6AD0b1Qcw1uUhrsLx38RERFZFKlvcOQnbcL9+IF2XeXXgRcmAZ4cBkZky5h0E1mhW5GP8eGqk9hyNlwtl/X3wrTOlVCpUG5z7xoRERGldeMI8Oco4MZh7bJfBaDNl0CRujxWRHYgS0l3QkICPDw8cOzYMVSoUMF0e0VEGbZuLztwDVP+PIOouES4ODlgaLNSeLtxCbg6p64aTET2jTGbyALE3Ae2fgwcklkFNICrF9BsHFCzP+DEti8ie5Glv3YXFxdVlTQpKcl0e0RE6bpyLxrv/34CwZfuqeUqgblV63ZpPy8eMSJ6AmM2kZm7kh9bAmyeCMRo4zYqdgFe/Bjw0s5ZT0T2I8tNY+PGjcMHH3yA+/fvm2aPiCiVpGQN/rfrElp+s1Ml3O4ujviwbTn8PrAeE24iyhRjNpEZ3DoO/NASWDNEm3DnLwf0Xg90ms+Em8hOZblfy6xZsxASEqKmAilSpAhy5syZ6vkjR44Yc/+I7Nr5sCi899txHLsWoZbrFs+LzztVRJG8qf/uiIjSw5hNlI0eRwDbPgMOzgc0yYCrJ9DkfaD224CTC08FkR3LctLdoUMH0+wJEaWIT0zG3B0XMXPrBSQkaeDl5owP2pZD15qBcHBw4JEiIoMwZhNlA40G+Gc5sGk8EH1Hu658R6Dlp0CuAJ4CIuI83YLzoZIlOX49QrVun70dpZabl/XFJ69UQAFvD3PvGhFlE8YlHheyEmGngPUjgavB2uV8pYE2XwDFm5h7z4jI2ufpjoiIwG+//YaLFy9i9OjR8PHxUd3K/fz8ULBgwefZbyK7FZuQhK83n8f8nZeQrAF8crpiYrsgtK8cwNZtInpmjNlEJhD7ENg+Bdg/D9AkAS45gMbvAXUGA86uPORE9HxJ9/Hjx9GiRQuV0V++fBn9+/dXSffKlStx9epVLFq0KKsvSWT39l+6h/dXnkDo3Wh1LNpVDsBH7YKQ19PN7o8NET07xmwiE3QlP/Eb8Pc44FGYdl259kCrKYB3IR5uIjJO9fIRI0agd+/euHDhAtzd3VPWt2nTBjt37szqyxHZtUdxiRi/+iRe+36fSrj9crlhfs8amNmtKhNuInpujNlERhR+FvipHbDyTW3C7VMC6PE78NrPTLiJyLgt3QcPHsS8efOeWC/dym/fvp3VlyOyW9vPheODlSdwMzJWLUuRtLFtysHbgxVOicg4GLOJjCAuCtgxFdg3B0hOBJw9gEYjgXrvAM7skUZEJki63dzc1IDxtM6fP4/8+fNn9eWI7E5ETDwmrzuNlUduqOVAHw983rES6pfMZ+5dIyIbw5hN9JxdyU+tAjaOA6JuateVfQlo+RmQpwgPLRGZrnt5+/btMXnyZCQkJKhlmb5IxnKPGTMGnTp1yurLEdmVP0/cQovpO1TCLTN/9a1fDBuHNWLCTUQmwZhN9IzunAd+7gD81kebcOcpCrz+K9B1CRNuIsoyB41GbuMZTsqhd+7cGYcOHUJUVBQCAgJUt/K6devizz//RM6cOWFtODULmVp4VCwmrD6FDae0QzBK+npiaqdKqF4kDw8+EZksLtlazGa8JpOLjwZ2fgHsnQUkJwBObkDDEUD9YYDLf7WMiIhMOmWYvOimTZuwe/duVRX10aNHqFatmqpoTkSpyT2t34/cwMfrTiPycQKcHR0wsEkJDGlWEm7OTjxcRGRSjNlEBpI2qDNrgQ1jgYfXtetKtQRaTwV8ivEwElH2tnTHxsamqlpuC3jnnEzh+oMYfLDqJHaev6OWKxTMpVq3ywd484ATUbbEJVuL2YzXZBL3LgJ/jgYubtEuexfWJttlWss4Sh50Isr+lu7cuXOjVq1aaNy4MZo2baq6qHl4eGT1ZYhsVnKyBov3X8HUv84iOj4Jrs6OGNaiFAY0LA5npyyXUSAiemaM2USZiI8Bdk8H9nwLJMUDTq5A/XeBBiMA1xw8dERkNFlOujdv3qzm496+fTu+/vprJCYmokaNGioJb9KkCV544QXj7R2Rlbl05xHG/H4cBy8/UMs1i+bB550qoUR+T3PvGhHZIcZsonRIJ89zfwEbxgARV7XrSjQH2nwB5C3BQ0ZE5u9erk8Sbt0coEuWLEFycjKSkpJgbdhdjZ5XYlIy5u8KxdebzyM+MRk5XJ3wfuuy6FG7CBwd2TWNiMwfl2whZjNe03O7Hwr8NQa4sFG7nKsQ0GoKUK4du5ITkcni0jP1dZU5ub///nv07NlTTRO2du1avPTSS5g+fTpM6fPPP1dTlA0bNizVeLXBgwcjb9688PT0VPsTFhZm0v0g0nf65kO88t1eTN1wViXcDUvlw9/DG6Fn3aJMuInI7LIzZt+4cQM9evRQMVmGnlWsWFFVTteR+/wTJkxAgQIF1PNShPXChQtG3w+iJyQ8BrZ/DsyurU24HV2ABsOBIQeAoPZMuInIsrqXFyxYEI8fP1ZdyeUh83NXqlRJJcOmpLs7Lz9L3/Dhw7F+/XqsWLFC3WUYMmQIOnbsiD179ph0f4jiEpMwa2sI5my/iMRkDXK5O2P8S0HoXL2Qyf8eiIgsLWY/ePAA9evXV/Ve/vrrL+TPn18l1Hny/Dc14rRp0zBjxgz89NNPKFasGMaPH4+WLVvi9OnTNlXwjSzM+b+Bv0YDDy5rl4s1Btp8CeQvbe49IyI7keWkW4Lo2bNn1Tyf8pBWZQnoOXKYruCETEvWvXt3zJ8/H5988knKemnGX7BgAZYuXYpmzZqpdQsXLkS5cuWwb98+1KlTx2T7RPbtyNUHeO+34wgJf6SWW5X3x+QO5eHrxYtGIrIc2Rmzp06disDAQBWHdSSx1m/l/uabb/Dhhx/i5ZdfVusWLVoEPz8/rF69Gl27djX6PpGde3BFOwXYufXaZa8CQMvPgPKvsGWbiLJVlruXHzt2TAXu999/H3Fxcfjggw+QL18+1KtXD+PGjTPJTkr38bZt2z4xF/jhw4eRkJCQan3ZsmVRuHBhBAcHm2RfyL7FxCdi8trT6DRnr0q483m64rvu1TD3jepMuInI4mRnzF6zZo0qrPrqq6/C19cXVatWVTfLdUJDQ9W+6Mds6aFWu3ZtxmwyrsQ4YOcX2q7kknA7OgP1hgJDDgIVOjLhJiLLb+nWTUHSvn171Y1MAvcff/yBZcuWYf/+/fj000+NuoPLly/HkSNHVPfytCR4u7q6qv3RJ3fN5bmMyIWHPPQHwBM9zd6Qu3h/5QlcvR+jljtWK4jxbYOQJ6crDx4RWazsitmXLl3CnDlzMGLECJXcS9x+5513VJzu1atXSlyWGG1ozGa8piwL2Qz8+R5w/6J2uWhDbVVy33I8mERkPUn3ypUr1XRh8pAxWD4+PmjQoAG++uorNW2YMV27dg3vvvsuNm3aZNSxXlOmTMGkSZOM9npk2x7GJuCz9Wew/OA1tRzg7Y5PO1ZE0zK+5t41IiKLidlSDV1auj/77DO1LC3dJ0+exNy5c1XS/SwYr8lgkde1XcnPrNEue/oBL34KVOzMlm0isr6k++2330ajRo0wYMAAFbClMqmpSPfx8PBwVKtWLWWdTG8i84TPmjULGzduRHx8PCIiIlK1dsuYNX9//wxfd+zYsepOvH5Lt4xDI0pr8+kwjFt9AmEPtT0jetQpjDGtysLL3YUHi4gsXnbGbKlIHhQUlGqd1Fj5/fff1f91cVlitGyrI8tVqlRJ9zUZr+mpEuOBfbOBHdOAhBjAwQmo/TbQ5H3A3TjT7RERZXvSLUlwdmnevDlOnDiRal2fPn3UuG2pwCqJsouLC7Zs2aKmQRHnzp3D1atXUbdu3Qxf183NTT2IMnLvURwmrT2NNf/cVMtF8+bA1E6VULt4Xh40IrIa2Rmzpfu6xOC005UVKVIkpaiaJN4Ss3VJttz0lm7uAwcOTPc1Ga8pU5e2A+tHAff+nXaucF1tVXL/CjxwRGT9Y7qltVkqjZ45c0Yty51tqUTq5ORk1J3z8vJChQqpPzhz5syp5v/Ure/Xr59qtZYuczIh+dChQ1XCzcrl9Cykuq4k2pJw34+Oh6MD0L9RcQxvURruLsb9/SYiyg7ZFbNlCk8ZMy7dy7t06YIDBw6o+cHlIWSasmHDhqlZSEqVKpUyZVhAQAA6dOhg1H0hG/fwJrBxHHBqpXY5Z37ghY+Byl3ZlZyIbCPpDgkJQZs2bXDjxg2UKVMmZcyVtDrLfNklSpRAdvr666/h6OioWrql4IrM9/ndd99l6z6QbbgdGYsPV5/A5jPalqGy/l6Y1rkSKhVKXaiPiMhaZGfMrlmzJlatWqW6hE+ePFkl1TJFmEz5qfPee+8hOjpadXeXoWEyvnzDhg2co5sMk5QA7J8LbP8ciH8EODgCNd8Emo4DPBirichyOWikaS8LJHjLtyxZskS1Lot79+6hR48eKvmVIG5tpHubTFsi835LaznZF/l9liJpUiwtKi4RLk4OGNK0FAY2KQFX5yzPqkdEZDFxydZiNuO1HQvdBfw5CrhzVrtcqBbQ9kugQGVz7xkR2bGHBsbrLLd079ixA/v27UsJ3kK6e3/++edqPBeRNbl6LwbvrzyOvRfvqeXKgbnxRedKKO3nZe5dIyJ6bozZZPWibgN/jwdO/KpdzpEXaDEJqNIdcOSNcSKyDllOuqWoSVRU1BPrHz16pObiJLIGScka/Lj3Mr7ceA6PE5Lg7uKIUS+WQZ/6xeAkA7mJiGwAYzZZGimSu3XrVjRr1kwV1ctQUiJw4Htg22dAvFx3OgA1+gLNPgRy/NfwQ0RkDbJ8i/Cll15SY7Gk2qh0WZOHtHzLtCTt27c3zV4SGdGFsCh0nrsXH687rRLuOsV9sOHdRnizYXEm3ERkUxizyRITbiFfZTldV4KBeY2AjWO1CXdANaD/VuCl6Uy4icg+WrpnzJiBXr16qQrhMl2XSExMVAn3t99+a4p9JDKKhKRkzN1+ETO3hiA+KRmebs74oE05dK0ZCEe2bhORDWLMJktMuHV0iXdKi/ejcGDTBOCfZdpljzxAi4+Aqj3ZlZyI7KuQmn5FVN30I+XKlUPJkiVhrViYxfaduB6J0b/9g7O3tUMjmpX1xaevVEABbw9z7xoRkcnjkq3EbMZr20m49TVr2hRbpnQFtn4CxEVqV1brBTSfCOTMm307SkRk7kJqycnJ+OKLL7BmzRrEx8erD9CJEyfCw4NJC1mu2IQkfLP5AubvuqTGcefJ4YKP2pdH+8oBas5YIiJbxJhN1pJwi63btqF5153Y0iunthp52+lAoRrZto9ERBYzpvvTTz/FBx98AE9PTxQsWFB1JR88eLBp947oORwIvY823+7C3B0XVcL9UqUC2DSiMV6uUpAJNxHZNMZsspaEW2fr5SQ0Xx8A9N/GhJuI7Ld7ealSpTBq1Ci89dZbannz5s1o27YtHj9+rOb6tGbsrmZbHsUlYtqGs1gUfEUt+3q54ZMOFfBieX9z7xoRUbbEJVuN2YzXtplw63tqVXMiIiuMSwZH3qtXr6JNmzYpyy1atFCthTdv3nz+vSUykh3n76Dl1ztTEu7XagSq1m0m3ERkTxizyRoT7qdWNScislIGJ91Sodzd3T3VOqlenpCQYIr9IkohwVdu8GQWhCNi4jHy13/Q64cDuBHxGIE+HljyZm1M7VwJ3h7aKvtERPaCMZvM6VkTbmN9PxGRpTG4kJr0Qu/duzfc3NxS1sXGxqr5uXPmzJmybuXKlcbfS7Jb6c3pmbbb2YaTt/Dh6lO4+ygOUhutd72iGN2yDHK4ZnlGPCIim8CYTeYkXcSfJ3GW7ycisiUGZyUyN3daPXr0MPb+EBk8p2d4VCwm/nEKf528rZ4rkT8npnWuhOpFfHgUiciuMWaTOUmM5phuIiIjzNNtS1iYxfI8LVhXqFkPjm0nIvJxApwcHTCwcQkMaVYS7i5O2bqfRESmwLjE42L14qLQvFpJbD0dbvC3sIgaEcHeC6kRZRdD7o6fPLgX534YjfIBubBmSH2MalmGCTcREZEluB8K/O8FbHk1Fs2KGVZXhQk3EdkyJt1kUbLSHS3u6nHErJ6I8gHeJt8vIiIiMkDoTmB+U+DOGcDTH1t27n3qGG0m3ERk65h0k8V4lvFf27dt49QiREREluDg/4CfXwEePwACqgIDtgGFaqgx3hkl3ky4icgeMOkmi8A5PYmIiKxUUgKwbjiwfiSQnAhUfBXo8xeQKyBlk/QSbybcRGQvmHSTReCcnkRERFYo+h6wqANw6Aepzws0nwh0nA+4eDyxqX7izYSbiOwJJzImi8A5PYmIiKxM2GlgWVcg4grg6gl0+h9QpnWm3yKJNxGRvWFLN1mEzMZ7PQ3vlhMREWWzs+uBBS9oE+48RYE3Nz814SYisldMuslifPz9L/AsViVL38OEm4jIMn3++edwcHDAsGHDUtbFxsZi8ODByJs3Lzw9PdGpUyeEhYWZdT8pizQaYOeXwPLuQPwjoGhDoP82wLccDyURUQaYdJPZaTQa/LT3Mt5YcAB5u3yCvKWrGfR9TLiJiCzTwYMHMW/ePFSqVCnV+uHDh2Pt2rVYsWIFduzYgZs3b6Jjx45m20/KovgY4Pd+wNaPJXoDNfsDb6wCcvjwUBIRZYJJN5lVXGIS3v/9BCauOYWkZA1eqVoQ108e4JyeRERW6tGjR+jevTvmz5+PPHnypKyPjIzEggULMH36dPUZX716dSxcuBB79+7Fvn37zLrPZIDIG8DC1sDJ3wFHZ+Clr4G2XwJOLjx8RERPwaSbzCY8Khavz9+PXw5dg6MD8EGbspjepTLcXZw4pycRkZWS7uNt27ZFixYtUq0/fPgwEhISUq0vW7YsChcujODg4HRfKy4uDg8fPkz1IDO4dhCY3xS4dQzw8AF6/gHU6MtTQURkIFYvJ7M4fj0Cb/18GLciY+Hl7oyZ3aqiSRnfJ4qrpZ2/m13KiYgs1/Lly3HkyBHVvTyt27dvw9XVFblz50613s/PTz2XnilTpmDSpEkm218ywLFlwNp3gKR4wDcI6LZMWziNiIgMxpZuynarj97Aq3ODVcJdIn9O/DG4/hMJtw7n9CQisg7Xrl3Du+++iyVLlsDd3d0orzl27FjVLV33kJ9B2SQ5Cfj7Q2D129qEu0xboN/fTLiJiJ4BW7op28iY7WkbzmLezktquVlZX3zTtQpyuWc+HoxzehIRWT7pPh4eHo5q1f4rhpmUlISdO3di1qxZ2LhxI+Lj4xEREZGqtVuql/v7+6f7mm5ubupB2Sw2EvitHxCySbvccBTQdBzgyLYaIqJnwaSbskXk4wS8s+wodpy/o5YHNSmBkS+WgZMM5iYiIqsnw4FOnDiRal2fPn3UuO0xY8YgMDAQLi4u6kaqTBUmzp07h6tXr6Ju3bpm2mt6wr2LwNLXgHsXAGcPoMNsoIL2fBER0bNh0k0mFxL+CAMWHcKlu9Fwd3HEF50ro13lAB55IiIb4uXlhQoVKqRalzNnTjUnt259v379MGLECPj4+CBXrlwYOnSoSrjr1Kljpr2mVC5uBVb01rZ05yoIdF0CBFTlQSIiek5Musmktp0NVy3cUXGJCPB2x/c9a6BCQW8edSIiO/T111/D0dFRtXRLZfKWLVviu+++M/dukUYD7J8HbPwA0CQBhWoCry0BvPx4bIiIjMBBo5FPWvsmU5B4e3urIi1y552en/xazd1xCdM2nlWxvFZRH3zXoxryeXJsHhER4xLjtcVIjAPWjwSO/qxdrvy6dg5uF+MUwyMismWG5pFs6SajexyfhDG/H8eaf26q5ddrF8ZH7crD1ZkFWIiIiCzGozvALz2Aa/sAB0fghY+BuoMBB9ZbISIyJibdZFQ3Ih7jrZ8P4eSNh3B2dMBH7cujR50iPMpERESW5NZxYPnrQOQ1wC0X0PkHoNQL5t4rIiKbxKSbjObg5fsYuPgw7j6Kh09OV8zpXg21i+flESYiIrIkp/8AVr0NJMQAPiWAbsuB/KXNvVdERDaLSTcZxbIDVzHhj5NISNKgXIFcmN+zOgrlycGjS0REZCmSk4EdU4Edn2uXizcFXl0IeOQx954REdk0ix5kO2XKFNSsWVNNQ+Lr64sOHTqoOT31xcbGYvDgwWpKEk9PT1URNSwszGz7bG8SkpIxfvVJjF15QiXcbSsVwO8D6zLhJiIisiTx0cCKXv8l3HUGAd1/Y8JNRGTvSfeOHTtUQr1v3z5s2rQJCQkJePHFFxEdHZ2yzfDhw7F27VqsWLFCbX/z5k107NjRrPttL+49ikOP/+3Hz/uuqJoro1uWwaxuVZHDlR0oiIiILEbEVWBBS+DMGsDRBWg/C2g1BXBivCYiyg5WNWXYnTt3VIu3JNeNGjVSpdnz58+PpUuXonPnzmqbs2fPoly5cggODkadOnUMel1OGZZ1p28+RP9Fh1ThNE83Z3zzWhW0COJ8nkRExsC4xONiNFeCtRXKY+4COfMDry0GCht2fURERMaJ1xbd0p2WvBnh4+Ojvh4+fFi1frdo0SJlm7Jly6Jw4cIq6SbTWH/8FjrN2asS7qJ5c2DVoHpMuImIiCzNkUXAT+20Cbd/RaD/NibcRERmYDX9ipKTkzFs2DDUr18fFSpUUOtu374NV1dX5M6dO9W2fn5+6rmMxMXFqYf+HQoy5Bxo8PXm85i5NUQtNyyVD7O6VYN3DhcePiIiIkuRlAj8/SGwf452OehloMMcwDWnufeMiMguWU3SLWO7T548id27dxulQNukSZOMsl/2Iio2AcN/+Qebz2iL1PVvWAxjWpWFs5NVdZYgIiKybY8fACv6AJe2aZebfAA0Gg04Ml4TEZmLVXwCDxkyBOvWrcO2bdtQqFChlPX+/v6Ij49HREREqu2lerk8l5GxY8eqruq6x7Vr10y6/9bu8t1odPxur0q4XZ0dMb1LZYxrG8SEm4iIyJLcOQ/Mb6ZNuF1yAF0WAU3GMOEmIjIzi27plhpvQ4cOxapVq7B9+3YUK1Ys1fPVq1eHi4sLtmzZoqYKEzKl2NWrV1G3bt0MX9fNzU096Ol2XbiDIUuPIvJxAvxyuWHeGzVQJTB1d34iIiIyswubgN/6AnEPAe9AoNsy7ThuIiIyO2dL71Iulcn/+OMPNVe3bpy2VIjz8PBQX/v164cRI0ao4mpSMU6SdEm4Da1cThnf8FiwOxSf/XkGyRqgauHcmNejOnxzufOQERERWQqZhGbvTGDTBFkACtcFuvwMeOY3954REZE1JN1z5mgLgDRp0iTV+oULF6J3797q/19//TUcHR1VS7cUR2vZsiW+++47s+yvrYhNSMK4VSfx+5Hrarlz9UL4pEMFuLs4mXvXiIiISCchFlg3DPhnmXa5Wk+gzVeAsyuPERGRBbHopNuQKcTd3d0xe/Zs9aDnF/YwFgN+Pox/rkXAydEB49qUQ5/6ReHg4MDDS0REZCmibmvn375+EHBwAlpNAWoNABiviYgsjkUn3ZS9jl59gLd+PozwqDh4e7hg9uvV0KBUPp4GIiIiS3LjCLC8OxB1E3DPDbz6I1Ciqbn3ioiIMsCkm5TfDl/HBytPID4pGaX9PDG/Zw0Uycv5PImIiCzKid+APwYDibFAvtJAt+VA3hLm3isiIsoEk247l5iUjM/+PIsf9oSq5ReD/DD9tSrwdOOvBhERkcVITga2fQLs+kq7XOpFoNP/AHdvc+8ZERE9BTMrOxYRE6+mA9sdclctv9O8FIY1LwVHR47fJiIishhxUcDKt4Bz67XL9d8Fmk8EHFnglIjIGjDptlPnw6LQf9EhXLkXgxyuTvjq1cpoXbGAuXeLiIiI9D24DCzrBoSfBpzcgPYzgMpdeYyIiKyIo7l3gLLf36du45XZe1TCXSiPB34fWI8JNxERPZcpU6agZs2a8PLygq+vLzp06IBz586l2iY2NhaDBw9G3rx54enpqab7DAsL45HPSOgu4Pum2oTb0w/o8ycTbiIiK8Sk247IFGwztlxQU4JFxyehbvG8WDOkAcoVyGXuXSMiIiu3Y8cOlVDv27cPmzZtQkJCAl588UVER0enbDN8+HCsXbsWK1asUNvfvHkTHTt2NOt+W6yDC4CfOwCP7wMBVYEB24FCNcy9V0RE9AwcNIZMhm3jHj58CG9vb0RGRiJXLttMQGPiEzFqxT/488RttdyrbhF8+FIQXJx434WIyNLYQly6c+eOavGW5LpRo0bqveTPnx9Lly5F586d1TZnz55FuXLlEBwcjDp16tjFcXmqpATgrzHAoQXa5QqdgZdnAS4e5t4zIiJ6xrjEMd124Nr9GDV+++ztKLg4OeDjlyuga63C5t4tIiKyYXIBInx8fNTXw4cPq9bvFi1apGxTtmxZFC5cOMOkOy4uTj30L25sWsx94NeewOVd0i4CNB8PNBgBOLDAKRGRNWMzp5X7+OOP4ejoqL6mJ/jiPbSftVsl3Pk83bCsfx0m3EREZFLJyckYNmwY6tevjwoVKqh1t2/fhqurK3Lnzp1qWz8/P/VcRuPEpQVB9wgMDLTdMxd+Bvi+iTbhdvUEui4FGo5kwk1EZAOYdFsxSbQnTJigxmrLV/3EW9b9HHwZbyzYjwcxCahY0BtrhtRHjaLaFgciIiJTkbHdJ0+exPLly5/rdcaOHatazHWPa9euwSad+wv4Xwsg4gqQpyjw5magbBtz7xURERkJu5dbecKtT7c8Zuw4TFxzEssOaC9OXq4SgKmdKsHdhfN5EhGRaQ0ZMgTr1q3Dzp07UahQoZT1/v7+iI+PR0RERKrWbqleLs+lx83NTT1slpTV2T0d2CI3zTVA0YZAl0VADt4gJyKyJWzptpGEW0fWV33lLZVwyxCwsa3L4pvXqjDhJiIik5IeVpJwr1q1Clu3bkWxYsVSPV+9enW4uLhgy5YtKetkSrGrV6+ibt269nd2Eh4Dv78JbJmsTbhrvgm8sYoJNxGRDWJLtw0l3Dqn1/0Pvo/isHzul2haxjfb9o2IiOy7S7lUJv/jjz/UXN26cdoyFtvDw0N97devH0aMGKGKq0mV16FDh6qE25DK5Tbl4U1g+evAzaOAozPQehpQs5+594qIiEyELd02lnDrhG//Gbt/nWfyfSIiIhJz5sxR466bNGmCAgUKpDx++eWXlAP09ddf46WXXkKnTp3UNGLSrXzlypX2dQCvH9IWTJOE28MHeGM1E24iIhvHlm4bTLh1dNuPHz/eRHtFRET0X/fyp3F3d8fs2bPVwy798wuwZiiQFAf4BmkrlPuk7oZPRES2h0m3jSbcOky8iYiIzCw5Cdj8EbB3hna5TFug4zzAzcvce0ZERNnAQWPIrWkb9/DhQzXWTLrFyRgzSyPzcD/PaXJwcFBzphIRkXWw9LhkLlZ5XGIjtQXTLvytXW44Cmg6ToK7ufeMiIiyKS7xE98KTJo0yazfT0RERM/g3kXgfy9oE25nd6DTAqD5eCbcRER2ht3LrYBuTPazdDGfPHkyx3QTERFlt4vbgBW9gdgIwCsA6LYUCKjK80BEZIfY0m0lRr//Aeq8OjBL38OEm4iIKJvJcLB9c4HFnbQJd8EawIBtTLiJiOwYk24rcDPiMV6dG4xbxdsiT8MeBn0PE24iIqJslhgPrH0H2DAG0CQBlbsBvdcDXv48FUREdoxJt4U7dPk+2s/agxM3IuGT0xXrF36jEurMMOEmIiLKZtF3gUUvA0cWAQ6OwIufAB3mAC7uPBVERHaOY7ot2PIDVzH+j5NISNKgrL8X5vesgUCfHKibyRhvJtxERETZ7PYJYNnrQORVwC0X0PkHoNQLPA1ERKQw6bZACUnJ+HjdaSwKvqKW21T0x5evVkYOV+dMi6sx4SYiIspmp9cAq94CEmIAnxJAt+VA/tI8DURElIJJt4W5Hx2PQUsOY9+l+2p55AulMaRZSTXXdlq6xHvixIlqWjDdMhEREWVDwbQd04Dtn2mXizcFXl0IeOThoSciolQcNBqJGvbN0EnNTe3MrYfov+gQrj94jJyuTvj6tSp4sTyLrxAR2RtLiUuWxmKOS3w0sHoQcHq1drn2QO0Ybie2ZRAR2ZOHBsYlRgcL8deJWxjx6z94nJCEInlzqPHbpf28zL1bREREpC/iGrC8m3Yct6ML8NJ0oFpPHiMiIsoQk24zS07W4JvN5zFja4hablgqH2Z2q4rcOVzNvWtERESk7+o+4JceQPQdIEc+4LXFQJG6PEZERJQpJt1m9CguESN+OYa/T4ep5X4NimFs67JwduJMbkRERBblyM/AuuFAcgLgVxHotgzIHWjuvSIiIivApNtMrtyLVuO3z4c9gquTIz7rWBGdqxcy1+4QERFRepISgU3jgX3faZfLtQdemQu45uTxIiIigzDpNoPdF+5i8NIjiHycAF8vN8x7ozqqFma1UyIiIovy+AHwW1/g4lbtcpOxQKP3AEf2SCMiIsMx6c5GUih+4Z7L+PTPM0hK1qByYG58/0Z1+OVyz87dICIioqe5ewFY1hW4FwK45NC2bge9zONGRERZxqQ7m8QlJmHcqpP47fB1tdypWiF8+koFuLs4ZdcuEBERkSEubNa2cMdFAt6BQNelQIFKPHZERPRMmHRng/CHsXhr8WEcvRoBRwdgXNsg9K1fFA4ODtnx44mIiMgQGg0QPFs7hluTDBSuC3T5GfDMz+NHRETPjEm3EUmX8QOh9xEeFQtfL3fUKuaDEzci8dbPhxD2MA7eHi6Y9XpVNCzF4E1ERGQ2yUnAlb3AozDA0w8oUg9ITtRWJz+2RLtN1TeAttMBZ07hSUREz8dmku7Zs2fjiy++wO3bt1G5cmXMnDkTtWrVyrafv+HkLUxaexq3ImNT1kmSHR2XiMRkDUr5emJ+zxoomo/VTomIyL6ZNWafXgNsGAM8vPnfOk9/wM1TO37bwQloNQWoNQBgjzQiIjICmyi/+csvv2DEiBGYOHEijhw5ogJ4y5YtER4enm0J98DFR1Il3EKqk0vCXamQN1YNrs+Em4iI7J5ZY7Yk3L/2TJ1wi0e3/yuY1uN3oPZbTLiJiMhobCLpnj59Ovr3748+ffogKCgIc+fORY4cOfDDDz9kS5dyaeHWZLLNnag4eLBgGhERkflitnQplxbuzCK2mxdQrBHPEhERGZXVJ93x8fE4fPgwWrRokbLO0dFRLQcHB6f7PXFxcXj48GGqx7OSMdxpW7jTkudlOyIiInuW1ZhtzHitxnCnbeFOS8Z4y3ZERERGZPVJ9927d5GUlAQ/P79U62VZxoqlZ8qUKfD29k55BAYGPvPPl6JpxtyOiIjIVmU1ZhszXquE2pjbERER2UvS/SzGjh2LyMjIlMe1a9ee+bWkSrkxtyMiIiLjx2tVpdyY2xEREdlL9fJ8+fLByckJYWGp70zLsr+/f7rf4+bmph7GINOCFfB2x+3I2HRHiclM3P7e2unDiIiI7FlWY7Yx47WaFixXAPDwVgbjuh20z8t2RERERmT1Ld2urq6oXr06tmzZkrIuOTlZLdetW9fkP9/J0QET2wWlJNj6dMvyvGxHRERkz8wasx1lKrCp/y5kELFbfa7djoiIyIisPukWMvXI/Pnz8dNPP+HMmTMYOHAgoqOjVWXU7NCqQgHM6VFNtWjrk2VZL88TERGRmWN2UHugyyIgV5q4LC3csl6eJyIiMjKr714uXnvtNdy5cwcTJkxQhViqVKmCDRs2PFGoxZQksX4hyF9VKZeiaTKGW7qUs4WbiIjIgmK2JNZl22qrlEvRNBnDLV3K2cJNREQm4qDRaDKbYtouyBQkUhVVirTkypXL3LtDRER2jnGJx4WIiGwnXttE93IiIiIiIiIiS8Skm4iIiIiIiMhEmHQTERERERERmQiTbiIiIiIiIiITsYnq5c9LV0tOBsITERGZmy4esdZpaozXRERkjfGaSTeAqKgodTACAwOz49wQEREZHJ+kKir9dzwE4zUREVlTvOaUYQCSk5Nx8+ZNeHl5wcHB4bnvdsjFwLVr12xm+jG+J+vBc2UdeJ6sh7nOldwxlwAeEBAAR0eOBNNhvLZ8tvj5Zm48pjym1sBef081BsZrtnTLwHZHRxQqVMioJ0B+2WztF47vyXrwXFkHnifrYY5zxRbuJzFeWw9b/HwzNx5THlNrYI+/p94G9Ejj7XMiIiIiIiIiE2HSTURERERERGQiTLqNzM3NDRMnTlRfbQXfk/XgubIOPE/WwxbPFWnx3JoGjyuPqTXg7ymPaXZjITUiIiIiIiIiE2FLNxEREREREZGJMOkmIiIiIiIiMhEm3UREREREREQmwqTbiGbPno2iRYvC3d0dtWvXxoEDB2AtpkyZgpo1a8LLywu+vr7o0KEDzp07l2qbJk2awMHBIdXj7bffhiX76KOPntjnsmXLpjwfGxuLwYMHI2/evPD09ESnTp0QFhYGSya/Y2nfkzzkfVjLedq5cyfatWuHgIAAtX+rV69O9bxGo8GECRNQoEABeHh4oEWLFrhw4UKqbe7fv4/u3buruSBz586Nfv364dGjR7DU95WQkIAxY8agYsWKyJkzp9qmZ8+euHnz5lPP7+effw5LPVe9e/d+Yn9btWpl0efqae8pvb8veXzxxRcWe57IvmK2udlibM1uthoHzckW45U15AeG/L1fvXoVbdu2RY4cOdTrjB49GomJibAnTLqN5JdffsGIESNUldsjR46gcuXKaNmyJcLDw2ENduzYof5g9u3bh02bNqkE4cUXX0R0dHSq7fr3749bt26lPKZNmwZLV758+VT7vHv37pTnhg8fjrVr12LFihXqGEgC1LFjR1iygwcPpno/cr7Eq6++ajXnSX6v5G9ELnrTI/s7Y8YMzJ07F/v371dJqvw9yQe7jgTFU6dOqfe/bt06FWwHDBgAS31fMTEx6rNh/Pjx6uvKlStV4Grfvv0T206ePDnV+Rs6dCgs9VwJuWjR399ly5alet7SztXT3pP+e5HHDz/8oC7O5ELCUs8T2VfMtgS2Fluzm63GQXOyxXhlDfnB0/7ek5KSVMIdHx+PvXv34qeffsKPP/6obirZFQ0ZRa1atTSDBw9OWU5KStIEBARopkyZYpVHODw8XCO/Hjt27EhZ17hxY827776rsSYTJ07UVK5cOd3nIiIiNC4uLpoVK1akrDtz5ox638HBwRprIeekRIkSmuTkZKs8T3K8V61albIs78Pf31/zxRdfpDpXbm5ummXLlqnl06dPq+87ePBgyjZ//fWXxsHBQXPjxg2NJb6v9Bw4cEBtd+XKlZR1RYoU0Xz99dcaS5Tee+rVq5fm5ZdfzvB7LP1cGXKe5P01a9Ys1TpLPk9kfzE7u9lDbM1OthoHzckW45Ul5geG/L3/+eefGkdHR83t27dTtpkzZ44mV65cmri4OI29YEu3Ecidm8OHD6uuPzqOjo5qOTg4GNYoMjJSffXx8Um1fsmSJciXLx8qVKiAsWPHqtY7SyfdsaSrUfHixdUdTOniIuScyR07/fMm3eMKFy5sNedNfvcWL16Mvn37qpY4az5POqGhobh9+3aq8+Lt7a26f+rOi3yVbl81atRI2Ua2l787aRGwpr8zOW/yXvRJN2XpplW1alXVpdnSu2Bt375ddRcrU6YMBg4ciHv37qU8Z+3nSrrIrV+/XnUxTMvazhPZbsw2B1uOreZmT3Ewu9lyvDJHfmDI37t8rVixIvz8/FK2kV4bDx8+VL0K7IWzuXfAFty9e1d1ndD/ZRKyfPbsWVib5ORkDBs2DPXr11dJm87rr7+OIkWKqCB7/PhxNT5VusdKN1lLJQFKurDIh6t0I5o0aRIaNmyIkydPqoDm6ur6RMIj502eswYyXikiIkKNU7Lm86RPd+zT+3vSPSdfJWjqc3Z2VkHAWs6ddBGUc9OtWzc1dkznnXfeQbVq1dR7kW5YctNEfnenT58OSyRd9aQbWbFixXDx4kV88MEHaN26tQqyTk5OVn+upBucjGVL2zXW2s4T2W7MNgdbj63mZi9xMLvZerwyR35gyN+7fPVL53dZ95y9YNJNT5CxGxI49cdnCf0xLXLHSop7NG/eXH1wlShRwiKPpHyY6lSqVEldKEhC+uuvv6rCJNZuwYIF6j1Kgm3N58neyF3hLl26qEI5c+bMSfWcjDPV/52VYPbWW2+pYiZubm6wNF27dk31+yb7LL9n0pogv3fWTsZzSyueFNuy5vNEZEy2HlvJNtl6vDJXfkCGYfdyI5BuvHKHLG2lPln29/eHNRkyZIgqHLFt2zYUKlQo020lyIqQkBBYC7kTV7p0abXPcm6km6G0FFvjebty5Qo2b96MN99806bOk+7YZ/b3JF/TFjySrr1SddTSz50u4ZbzJ0VJ9Fu5Mzp/8t4uX74MayBdTeUzUff7Zs3nateuXaqXyNP+xqzxPNkzW4rZlsKWYqslsPU4aClsKV6ZKz8w5O9dvoal87use85eMOk2AmnhqF69OrZs2ZKqC4Ys161bF9ZAWtzkD2rVqlXYunWr6nrzNMeOHVNfpSXVWsi0D9LiK/ss58zFxSXVeZMLbBmXZg3nbeHChaoblFSEtKXzJL978iGsf15k3I+Mp9KdF/kqH/AylkhHfm/l7053k8GSE24ZCyk3TGQ88NPI+ZPxZGm7vFmq69evqzFyut83az1Xup4k8jkh1XBt7TzZM1uI2ZbGlmKrJbDlOGhJbClemSs/MOTvXb6eOHEi1Q0NXaNDUFAQ7Ia5K7nZiuXLl6uqkj/++KOqfjhgwABN7ty5U1Xqs2QDBw7UeHt7a7Zv3665detWyiMmJkY9HxISopk8ebLm0KFDmtDQUM0ff/yhKV68uKZRo0YaSzZy5Ej1nmSf9+zZo2nRooUmX758qvqiePvttzWFCxfWbN26Vb23unXrqoelk0q7st9jxoxJtd5azlNUVJTm6NGj6iEfQ9OnT1f/11Xx/vzzz9Xfj+z/8ePHVbXRYsWKaR4/fpzyGq1atdJUrVpVs3//fs3u3bs1pUqV0nTr1s1i31d8fLymffv2mkKFCmmOHTuW6u9MV71z7969qiK2PH/x4kXN4sWLNfnz59f07NnTIt+TPDdq1ChVoVR+3zZv3qypVq2aOhexsbEWe66e9vsnIiMjNTly5FAVVtOyxPNE9hWzzc1WY2t2stU4aE62GK8sPT8w5O89MTFRU6FCBc2LL76o4uaGDRtUzBw7dqzGnjDpNqKZM2eqXzpXV1c1Hcm+ffs01kI+nNJ7LFy4UD1/9epVlbj5+PioC5WSJUtqRo8erS5MLdlrr72mKVCggDonBQsWVMuSmOpI8Bo0aJAmT5486gL7lVdeUR8mlm7jxo3q/Jw7dy7Vems5T9u2bUv3902m89BNlzJ+/HiNn5+feh/Nmzd/4r3eu3dPBUJPT0817USfPn1UUDWnzN6XBPmM/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/65] [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/65] 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/65] 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/65] 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/65] 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/65] 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/65] [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/65] 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/65] 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/65] 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/65] 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/65] [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) From b2ad54a2f458e2cf8d0434b1c4936df3e5be2e6c Mon Sep 17 00:00:00 2001 From: Felix <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:45:52 +0200 Subject: [PATCH 31/65] feat: PiecewiseFormulation return type, model repr, rename to add_piecewise_formulation (#642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: PiecewiseFormulation return type, model groups, rename to add_piecewise_formulation - Add PiecewiseFormulation dataclass grouping all auxiliary variables and constraints created by a piecewise formulation - Add _groups registry on Model to track grouped artifacts - Model repr hides grouped items from Variables/Constraints sections and shows them in a new "Groups" section - Rename add_piecewise_constraints -> add_piecewise_formulation - Export PiecewiseFormulation from linopy Co-Authored-By: Claude Opus 4.6 (1M context) * docs: update notebook to show PiecewiseFormulation repr Reorder cells so add_piecewise_formulation is the last statement, letting Jupyter display the PiecewiseFormulation repr automatically. Add print(m) cell to show the grouped model repr. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: show tangent_lines repr in notebook Split tangent_lines cell so its LinearExpression repr is displayed. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: show dims in PiecewiseFormulation repr and user dims in Model groups PiecewiseFormulation now shows full dims (including internal) for each variable and constraint. Model groups section shows "over (dim1, dim2)" for user-facing dims only. Co-Authored-By: Claude Opus 4.6 (1M context) * refac: remove counts from PiecewiseFormulation repr Match style of Variables/Constraints containers which don't show counts. Co-Authored-By: Claude Opus 4.6 (1M context) * refac: rename _groups to _piecewise_formulations, use direct section name Replace generic "Groups" with "Piecewise Formulations" in Model repr. Rename internal registry and helper to match. Co-Authored-By: Claude Opus 4.6 (1M context) * refac: move method after counts in repr to avoid looking like a dim Co-Authored-By: Claude Opus 4.6 (1M context) * refac: show dims before name like regular variables/constraints Co-Authored-By: Claude Opus 4.6 (1M context) * refac: compact piecewise formulation line in model repr Co-Authored-By: Claude Opus 4.6 (1M context) * refac: use backtick name style in PiecewiseFormulation repr Match Constraint repr pattern: `name` instead of 'name'. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: show user dims with sizes in PiecewiseFormulation header Match Constraint repr style: `name` [dim: size, ...] — method Co-Authored-By: Claude Opus 4.6 (1M context) * fix: clear notebook outputs to fix nbformat validation Remove jetTransient metadata and normalize cell format. Co-Authored-By: Claude Opus 4.6 (1M context) * refac: store names in PiecewiseFormulation, add IO persistence PiecewiseFormulation now stores variable/constraint names as strings with a model reference. Properties return live Views on access. This makes serialization trivial — persist as JSON in netcdf attrs, reconstruct on load. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: rename remaining add_piecewise_constraints reference after rebase Co-Authored-By: Claude Opus 4.6 (1M context) * refac: rename PWL suffix constants for clarity - PWL_X_LINK_SUFFIX/_Y_LINK_SUFFIX → PWL_LINK_SUFFIX (N-var, single link) - PWL_BINARY_SUFFIX → PWL_SEGMENT_BINARY_SUFFIX (disjunctive segment selection) - PWL_INC_BINARY_SUFFIX → PWL_ORDER_BINARY_SUFFIX (incremental ordering) - PWL_INC_LINK_SUFFIX → PWL_DELTA_BOUND_SUFFIX (δ ≤ binary) - PWL_INC_ORDER_SUFFIX → PWL_BINARY_ORDER_SUFFIX (binary_{i+1} ≤ δ_i) - PWL_FILL_SUFFIX → PWL_FILL_ORDER_SUFFIX (δ_{i+1} ≤ δ_i) 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/api.rst | 2 +- doc/piecewise-linear-constraints.rst | 30 +- doc/release_notes.rst | 2 +- examples/piecewise-linear-constraints.ipynb | 586 +++++++++++--------- linopy/__init__.py | 9 +- linopy/constants.py | 13 +- linopy/constraints.py | 28 +- linopy/io.py | 23 + linopy/model.py | 49 +- linopy/piecewise.py | 170 ++++-- linopy/variables.py | 28 +- test/test_piecewise_constraints.py | 166 +++--- 12 files changed, 656 insertions(+), 450 deletions(-) diff --git a/doc/api.rst b/doc/api.rst index 1ad7d869..434a77b8 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -18,7 +18,7 @@ Creating a model model.Model.add_variables model.Model.add_constraints model.Model.add_objective - model.Model.add_piecewise_constraints + model.Model.add_piecewise_formulation piecewise.breakpoints piecewise.segments piecewise.tangent_lines diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index bb9eebbd..6decb39c 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -24,7 +24,7 @@ Quick Start fuel = m.add_variables(name="fuel") # Link power and fuel via a piecewise linear curve - m.add_piecewise_constraints( + m.add_piecewise_formulation( (power, [0, 30, 60, 100]), (fuel, [0, 36, 84, 170]), ) @@ -38,12 +38,12 @@ of adjacent breakpoints. API --- -``add_piecewise_constraints`` +``add_piecewise_formulation`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python - m.add_piecewise_constraints( + m.add_piecewise_formulation( (expr1, breakpoints1), (expr2, breakpoints2), ..., @@ -97,7 +97,7 @@ linopy provides two distinct tools for piecewise linear modelling. :widths: 30 35 35 * - - - ``add_piecewise_constraints`` + - ``add_piecewise_formulation`` - ``tangent_lines`` * - **Constraint type** - Equality: :math:`y = f(x)` @@ -117,7 +117,7 @@ linopy provides two distinct tools for piecewise linear modelling. ``tangent_lines`` does **not** work with equality. Writing ``fuel == tangent_lines(...)`` creates one equality per segment, which is overconstrained (infeasible except at breakpoints). - Use ``add_piecewise_constraints`` for equality. + Use ``add_piecewise_formulation`` for equality. **When is the tangent-line bound tight?** @@ -137,7 +137,7 @@ The simplest form --- pass Python lists directly in the tuple: .. code-block:: python - m.add_piecewise_constraints( + m.add_piecewise_formulation( (power, [0, 30, 60, 100]), (fuel, [0, 36, 84, 170]), ) @@ -149,7 +149,7 @@ Equivalent, but explicit about the DataArray construction: .. code-block:: python - m.add_piecewise_constraints( + m.add_piecewise_formulation( (power, linopy.breakpoints([0, 30, 60, 100])), (fuel, linopy.breakpoints([0, 36, 84, 170])), ) @@ -161,7 +161,7 @@ When you know marginal costs (slopes) rather than absolute values: .. code-block:: python - m.add_piecewise_constraints( + m.add_piecewise_formulation( (power, [0, 50, 100, 150]), ( cost, @@ -180,7 +180,7 @@ Different generators can have different curves. Pass a dict to .. code-block:: python - m.add_piecewise_constraints( + m.add_piecewise_formulation( ( power, linopy.breakpoints( @@ -206,7 +206,7 @@ For disconnected operating regions (e.g. forbidden zones), use .. code-block:: python - m.add_piecewise_constraints( + m.add_piecewise_formulation( (power, linopy.segments([(0, 0), (50, 80)])), (cost, linopy.segments([(0, 0), (125, 200)])), ) @@ -222,7 +222,7 @@ are symmetric --- there is no distinguished "x" or "y": .. code-block:: python - m.add_piecewise_constraints( + m.add_piecewise_formulation( (power, [0, 30, 60, 100]), (fuel, [0, 40, 85, 160]), (heat, [0, 25, 55, 95]), @@ -263,7 +263,7 @@ non-zero, so every expression is interpolated within the same segment. .. code-block:: python - m.add_piecewise_constraints((power, xp), (fuel, yp), method="sos2") + m.add_piecewise_formulation((power, xp), (fuel, yp), method="sos2") Incremental (Delta) Formulation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -279,7 +279,7 @@ For **strictly monotonic** breakpoints. Uses fill-fraction variables .. code-block:: python - m.add_piecewise_constraints((power, xp), (fuel, yp), method="incremental") + m.add_piecewise_formulation((power, xp), (fuel, yp), method="incremental") **Limitation:** All breakpoint sequences must be strictly monotonic. @@ -330,7 +330,7 @@ linked expressions) are forced to zero: .. code-block:: python commit = m.add_variables(name="commit", binary=True, coords=[time]) - m.add_piecewise_constraints( + m.add_piecewise_formulation( (power, [30, 60, 100]), (fuel, [40, 90, 170]), active=commit, @@ -352,7 +352,7 @@ You don't need ``expand_dims``: y = m.add_variables(name="y", coords=[time]) # 1D breakpoints auto-expand to match x's time dimension - m.add_piecewise_constraints((x, [0, 50, 100]), (y, [0, 70, 150])) + m.add_piecewise_formulation((x, [0, 50, 100]), (y, [0, 70, 150])) NaN masking ~~~~~~~~~~~ diff --git a/doc/release_notes.rst b/doc/release_notes.rst index d1c7efea..40cd98d5 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -11,7 +11,7 @@ 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()`` 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 ``add_piecewise_formulation()`` for piecewise linear equality constraints with SOS2, incremental, and disjunctive formulations: ``m.add_piecewise_formulation((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. diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 7f6a473a..71d10a11 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-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```" + "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_formulation((power, x_pts), (fuel, y_pts))\n```" }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:20.809300Z", + "start_time": "2026-04-01T17:50:20.202138Z" + }, "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:08:36.934172Z", - "start_time": "2026-04-01T11:08:36.927037Z" } }, + "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "import pandas as pd\n", @@ -103,9 +105,7 @@ " )\n", " ax2.legend()\n", " plt.tight_layout()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -120,127 +120,141 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:20.848260Z", + "start_time": "2026-04-01T17:50:20.813939Z" + }, "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:08:36.947252Z", - "start_time": "2026-04-01T11:08:36.944290Z" } }, + "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-01T17:50:20.884905Z", + "start_time": "2026-04-01T17:50:20.851433Z" + }, "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:08:36.999555Z", - "start_time": "2026-04-01T11:08:36.951114Z" } }, + "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", + "demand1 = xr.DataArray([50, 80, 30], coords=[time])\n", + "m1.add_constraints(power >= demand1, name=\"demand\")\n", + "m1.add_objective(fuel.sum())\n", + "\n", "# breakpoints are auto-broadcast to match the time dimension\n", - "m1.add_piecewise_constraints(\n", + "m1.add_piecewise_formulation(\n", " (power, x_pts1),\n", " (fuel, 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-01T17:50:20.889691Z", + "start_time": "2026-04-01T17:50:20.888003Z" + } + }, "outputs": [], - "execution_count": null + "source": [ + "print(m1)" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:20.941957Z", + "start_time": "2026-04-01T17:50:20.900785Z" + }, "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:08:37.057492Z", - "start_time": "2026-04-01T11:08:37.002487Z" } }, + "outputs": [], "source": [ "m1.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:20.957062Z", + "start_time": "2026-04-01T17:50:20.946704Z" + }, "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:08:37.072609Z", - "start_time": "2026-04-01T11:08:37.068099Z" } }, + "outputs": [], "source": [ "m1.solution[[\"power\", \"fuel\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:21.068805Z", + "start_time": "2026-04-01T17:50:20.970458Z" + }, "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:08:37.172658Z", - "start_time": "2026-04-01T11:08:37.081859Z" } }, + "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,126 +269,126 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:21.074995Z", + "start_time": "2026-04-01T17:50:21.072706Z" + }, "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:08:37.180064Z", - "start_time": "2026-04-01T11:08:37.176417Z" } }, + "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-01T17:50:21.135253Z", + "start_time": "2026-04-01T17:50:21.083396Z" + }, "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:08:37.578537Z", - "start_time": "2026-04-01T11:08:37.187530Z" } }, + "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", + "demand2 = xr.DataArray([80, 120, 50], coords=[time])\n", + "m2.add_constraints(power >= demand2, name=\"demand\")\n", + "m2.add_objective(fuel.sum())\n", + "\n", + "m2.add_piecewise_formulation(\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())" - ], - "outputs": [], - "execution_count": null + ")" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:21.185956Z", + "start_time": "2026-04-01T17:50:21.147474Z" + }, "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:08:37.626072Z", - "start_time": "2026-04-01T11:08:37.583238Z" } }, + "outputs": [], "source": [ "m2.solve(reformulate_sos=\"auto\");" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:21.205500Z", + "start_time": "2026-04-01T17:50:21.200814Z" + }, "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:08:37.636391Z", - "start_time": "2026-04-01T11:08:37.631610Z" } }, + "outputs": [], "source": [ "m2.solution[[\"power\", \"fuel\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:21.289611Z", + "start_time": "2026-04-01T17:50:21.213993Z" + }, "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:08:37.743315Z", - "start_time": "2026-04-01T11:08:37.644492Z" } }, + "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,19 +408,21 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:21.296416Z", + "start_time": "2026-04-01T17:50:21.293422Z" + }, "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:08:37.762965Z", - "start_time": "2026-04-01T11:08:37.758436Z" } }, + "outputs": [], "source": [ "# x-breakpoints define where each segment lives on the power axis\n", "# y-breakpoints define the corresponding cost values\n", @@ -414,25 +430,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-01T17:50:21.351922Z", + "start_time": "2026-04-01T17:50:21.304030Z" + }, "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:08:37.845482Z", - "start_time": "2026-04-01T11:08:37.775373Z" } }, + "outputs": [], "source": [ "m3 = linopy.Model()\n", "\n", @@ -440,60 +456,58 @@ "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", + "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())\n", + "\n", + "m3.add_piecewise_formulation(\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())" - ], - "outputs": [], - "execution_count": null + ")" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:21.398282Z", + "start_time": "2026-04-01T17:50:21.355402Z" + }, "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:08:37.920203Z", - "start_time": "2026-04-01T11:08:37.848081Z" } }, + "outputs": [], "source": [ "m3.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:21.413359Z", + "start_time": "2026-04-01T17:50:21.408184Z" + }, "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:08:37.935150Z", - "start_time": "2026-04-01T11:08:37.929245Z" } }, + "outputs": [], "source": [ "m3.solution[[\"power\", \"cost\", \"backup\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -883,19 +897,21 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:21.449956Z", + "start_time": "2026-04-01T17:50:21.433179Z" + }, "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:08:37.974567Z", - "start_time": "2026-04-01T11:08:37.947618Z" } }, + "outputs": [], "source": [ "x_pts4 = linopy.breakpoints([0, 40, 80, 120])\n", "# Concave curve: decreasing marginal fuel per MW\n", @@ -906,81 +922,93 @@ "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())" - ], + "m4.add_objective(-fuel.sum())\n", + "\n", + "# tangent_lines returns one LinearExpression per segment — pure LP, no aux variables\n", + "linopy.tangent_lines(power, x_pts4, y_pts4)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:21.470263Z", + "start_time": "2026-04-01T17:50:21.454181Z" + } + }, "outputs": [], - "execution_count": null + "source": [ + "t = linopy.tangent_lines(power, x_pts4, y_pts4)\n", + "m4.add_constraints(fuel <= t, name=\"pwl\")" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:21.498563Z", + "start_time": "2026-04-01T17:50:21.476327Z" + }, "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:08:38.006772Z", - "start_time": "2026-04-01T11:08:37.980912Z" } }, + "outputs": [], "source": [ "m4.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:21.512519Z", + "start_time": "2026-04-01T17:50:21.508408Z" + }, "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:08:38.016635Z", - "start_time": "2026-04-01T11:08:38.012572Z" } }, + "outputs": [], "source": [ "m4.solution[[\"power\", \"fuel\"]].to_pandas()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:21.608738Z", + "start_time": "2026-04-01T17:50:21.525541Z" + }, "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:08:38.127204Z", - "start_time": "2026-04-01T11:08:38.036942Z" } }, + "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 +1023,27 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:50:21.614899Z", + "start_time": "2026-04-01T17:50:21.612589Z" + }, "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:08:38.135897Z", - "start_time": "2026-04-01T11:08:38.133078Z" } }, + "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,12 +1960,14 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:08:38.148433Z", - "start_time": "2026-04-01T11:08:38.145204Z" + "end_time": "2026-04-01T17:50:21.626940Z", + "start_time": "2026-04-01T17:50:21.624504Z" } }, + "outputs": [], "source": [ "# Unit parameters: operates between 30-100 MW when on\n", "p_min, p_max = 30, 100\n", @@ -1948,18 +1978,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:08:38.256777Z", - "start_time": "2026-04-01T11:08:38.161249Z" + "end_time": "2026-04-01T17:50:21.707770Z", + "start_time": "2026-04-01T17:50:21.635963Z" } }, + "outputs": [], "source": [ "m6 = linopy.Model()\n", "\n", @@ -1967,70 +1997,68 @@ "fuel = m6.add_variables(name=\"fuel\", lower=0, coords=[time])\n", "commit = m6.add_variables(name=\"commit\", binary=True, coords=[time])\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 /MW\n", + "m6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())\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", + "m6.add_piecewise_formulation(\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\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())" - ], - "outputs": [], - "execution_count": null + ")" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:08:38.332350Z", - "start_time": "2026-04-01T11:08:38.263473Z" + "end_time": "2026-04-01T17:50:21.766060Z", + "start_time": "2026-04-01T17:50:21.710952Z" } }, + "outputs": [], "source": [ "m6.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:08:38.341128Z", - "start_time": "2026-04-01T11:08:38.336172Z" + "end_time": "2026-04-01T17:50:21.775881Z", + "start_time": "2026-04-01T17:50:21.770500Z" } }, + "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:08:38.440499Z", - "start_time": "2026-04-01T11:08:38.353813Z" + "end_time": "2026-04-01T17:50:21.866232Z", + "start_time": "2026-04-01T17:50:21.784879Z" } }, + "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,12 +2760,14 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:08:38.453545Z", - "start_time": "2026-04-01T11:08:38.450339Z" + "end_time": "2026-04-01T17:50:21.872549Z", + "start_time": "2026-04-01T17:50:21.869653Z" } }, + "outputs": [], "source": [ "# CHP operating points: as load increases, power, fuel, and heat all change\n", "bp_chp = linopy.breakpoints(\n", @@ -2750,18 +2780,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:08:38.518849Z", - "start_time": "2026-04-01T11:08:38.466354Z" + "end_time": "2026-04-01T17:50:21.920666Z", + "start_time": "2026-04-01T17:50:21.879829Z" } }, + "outputs": [], "source": [ "m7 = linopy.Model()\n", "\n", @@ -2769,65 +2799,62 @@ "fuel = m7.add_variables(name=\"fuel\", lower=0, coords=[time])\n", "heat = m7.add_variables(name=\"heat\", lower=0, coords=[time])\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", + "m7.add_objective(fuel.sum())\n", + "\n", "# N-variable: all three linked through shared interpolation weights\n", - "m7.add_piecewise_constraints(\n", + "m7.add_piecewise_formulation(\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())" - ], - "outputs": [], - "execution_count": null + ")" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:08:38.581845Z", - "start_time": "2026-04-01T11:08:38.522785Z" + "end_time": "2026-04-01T17:50:21.964861Z", + "start_time": "2026-04-01T17:50:21.926856Z" } }, + "outputs": [], "source": [ "m7.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:08:38.632933Z", - "start_time": "2026-04-01T11:08:38.620498Z" + "end_time": "2026-04-01T17:50:21.981116Z", + "start_time": "2026-04-01T17:50:21.976461Z" } }, + "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:08:38.743684Z", - "start_time": "2026-04-01T11:08:38.645091Z" + "end_time": "2026-04-01T17:50:22.088132Z", + "start_time": "2026-04-01T17:50:22.003877Z" } }, + "outputs": [], "source": [ "plot_pwl_results(m7, bp_chp, power_dispatch, x_name=\"fuel\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -2836,12 +2863,14 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:08:38.759346Z", - "start_time": "2026-04-01T11:08:38.752115Z" + "end_time": "2026-04-01T17:50:22.097775Z", + "start_time": "2026-04-01T17:50:22.094150Z" } }, + "outputs": [], "source": [ "gens = pd.Index([\"gas\", \"coal\"], name=\"gen\")\n", "\n", @@ -2854,91 +2883,96 @@ ")\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:08:38.852492Z", - "start_time": "2026-04-01T11:08:38.765098Z" + "end_time": "2026-04-01T17:50:22.177554Z", + "start_time": "2026-04-01T17:50:22.111112Z" } }, + "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", + "demand8 = xr.DataArray([80, 120, 60], coords=[time])\n", + "m8.add_constraints(power.sum(\"gen\") >= demand8, name=\"demand\")\n", + "m8.add_objective(fuel.sum())\n", + "\n", "# Per-entity breakpoints: each generator gets its own curve\n", - "m8.add_piecewise_constraints(\n", + "m8.add_piecewise_formulation(\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())" - ], - "outputs": [], - "execution_count": null + ")" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:08:38.923105Z", - "start_time": "2026-04-01T11:08:38.855310Z" + "end_time": "2026-04-01T17:50:22.234795Z", + "start_time": "2026-04-01T17:50:22.185178Z" } }, + "outputs": [], "source": [ "m8.solve(reformulate_sos=\"auto\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:08:38.943143Z", - "start_time": "2026-04-01T11:08:38.934884Z" + "end_time": "2026-04-01T17:50:22.245727Z", + "start_time": "2026-04-01T17:50:22.242646Z" } }, - "source": [ - "m8.solution[[\"power\", \"fuel\"]].to_dataframe().round(2)" - ], "outputs": [], - "execution_count": null + "source": [ + "m8.constraints[\"demand\"]" + ] }, { "cell_type": "code", + "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T11:08:39.047739Z", - "start_time": "2026-04-01T11:08:38.949442Z" + "end_time": "2026-04-01T17:50:22.346404Z", + "start_time": "2026-04-01T17:50:22.260902Z" } }, - "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 - } - } - ], - "execution_count": null + "outputs": [], + "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", + " 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", + "\n", + "plt.tight_layout()" + ] } ], "metadata": { diff --git a/linopy/__init__.py b/linopy/__init__.py index aa14b767..fb54c6c4 100644 --- a/linopy/__init__.py +++ b/linopy/__init__.py @@ -20,7 +20,13 @@ 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, segments, slopes_to_points, tangent_lines +from linopy.piecewise import ( + PiecewiseFormulation, + breakpoints, + segments, + slopes_to_points, + tangent_lines, +) from linopy.remote import RemoteHandler try: @@ -38,6 +44,7 @@ "Model", "Objective", "OetcHandler", + "PiecewiseFormulation", "QuadraticExpression", "RemoteHandler", "Variable", diff --git a/linopy/constants.py b/linopy/constants.py index 0d8d4adc..268a7ac1 100644 --- a/linopy/constants.py +++ b/linopy/constants.py @@ -40,15 +40,14 @@ PWL_LAMBDA_SUFFIX = "_lambda" PWL_CONVEX_SUFFIX = "_convex" -PWL_X_LINK_SUFFIX = "_x_link" -PWL_Y_LINK_SUFFIX = "_y_link" +PWL_LINK_SUFFIX = "_link" PWL_DELTA_SUFFIX = "_delta" -PWL_FILL_SUFFIX = "_fill" -PWL_BINARY_SUFFIX = "_binary" +PWL_FILL_ORDER_SUFFIX = "_fill_order" +PWL_SEGMENT_BINARY_SUFFIX = "_segment_binary" PWL_SELECT_SUFFIX = "_select" -PWL_INC_BINARY_SUFFIX = "_inc_binary" -PWL_INC_LINK_SUFFIX = "_inc_link" -PWL_INC_ORDER_SUFFIX = "_inc_order" +PWL_ORDER_BINARY_SUFFIX = "_order_binary" +PWL_DELTA_BOUND_SUFFIX = "_delta_bound" +PWL_BINARY_ORDER_SUFFIX = "_binary_order" PWL_ACTIVE_BOUND_SUFFIX = "_active_bound" BREAKPOINT_DIM = "_breakpoint" SEGMENT_DIM = "_segment" diff --git a/linopy/constraints.py b/linopy/constraints.py index bb6d8e68..a846b681 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -733,25 +733,35 @@ def _formatted_names(self) -> dict[str, str]: """ return {format_string_as_variable_name(n): n for n in self} - def __repr__(self) -> str: - """ - Return a string representation of the linopy model. - """ - r = "linopy.model.Constraints" - line = "-" * len(r) - r += f"\n{line}\n" - + def _format_items(self, exclude: set[str] | None = None) -> str: + """Format constraint items, optionally excluding names in a group.""" + r = "" + count = 0 for name, ds in self.items(): + if exclude and name in exclude: + continue + count += 1 coords = ( " (" + ", ".join([str(c) for c in ds.coords.keys()]) + ")" if ds.coords else "" ) r += f" * {name}{coords}\n" - if not len(list(self)): + if count == 0: r += "\n" return r + def __repr__(self) -> str: + r = "linopy.model.Constraints" + line = "-" * len(r) + r += f"\n{line}\n" + r += self._format_items() + return r + + def _repr_filtered(self, exclude: set[str]) -> str: + """Format items excluding grouped names (used by Model.__repr__).""" + return self._format_items(exclude) + @overload def __getitem__(self, names: str) -> Constraint: ... diff --git a/linopy/io.py b/linopy/io.py index f2929398..a753b828 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -1147,6 +1147,17 @@ def with_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: ds = ds.assign_attrs(scalars) if m._relaxed_registry: ds.attrs["_relaxed_registry"] = json.dumps(m._relaxed_registry) + if m._piecewise_formulations: + ds.attrs["_piecewise_formulations"] = json.dumps( + { + name: { + "method": pwl.method, + "variables": pwl.variable_names, + "constraints": pwl.constraint_names, + } + for name, pwl in m._piecewise_formulations.items() + } + ) ds.attrs = non_bool_dict(ds.attrs) for k in ds: @@ -1244,6 +1255,18 @@ def get_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: if "_relaxed_registry" in ds.attrs: m._relaxed_registry = json.loads(ds.attrs["_relaxed_registry"]) + if "_piecewise_formulations" in ds.attrs: + from linopy.piecewise import PiecewiseFormulation + + for name, d in json.loads(ds.attrs["_piecewise_formulations"]).items(): + m._piecewise_formulations[name] = PiecewiseFormulation( + name=name, + method=d["method"], + variable_names=d["variables"], + constraint_names=d["constraints"], + model=m, + ) + return m diff --git a/linopy/model.py b/linopy/model.py index 2a635680..366b75a4 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -12,7 +12,7 @@ from collections.abc import Callable, Mapping, Sequence from pathlib import Path from tempfile import NamedTemporaryFile, gettempdir -from typing import Any, Literal, overload +from typing import TYPE_CHECKING, Any, Literal, overload from warnings import warn import numpy as np @@ -70,7 +70,7 @@ from linopy.matrices import MatrixAccessor from linopy.objective import Objective from linopy.piecewise import ( - add_piecewise_constraints, + add_piecewise_formulation, ) from linopy.remote import RemoteHandler @@ -97,6 +97,9 @@ ) from linopy.variables import ScalarVariable, Variable, Variables +if TYPE_CHECKING: + from linopy.piecewise import PiecewiseFormulation + logger = logging.getLogger(__name__) @@ -228,6 +231,7 @@ class Model: "_auto_mask", "_solver_dir", "_relaxed_registry", + "_piecewise_formulations", "solver_model", "solver_name", "matrices", @@ -284,6 +288,7 @@ def __init__( self._chunk: T_Chunks = chunk self._force_dim_names: bool = bool(force_dim_names) self._auto_mask: bool = bool(auto_mask) + self._piecewise_formulations: dict[str, PiecewiseFormulation] = {} self._relaxed_registry: dict[str, str] = {} self._solver_dir: Path = Path( gettempdir() if solver_dir is None else solver_dir @@ -493,17 +498,45 @@ def __repr__(self) -> str: """ Return a string representation of the linopy model. """ - var_string = self.variables.__repr__().split("\n", 2)[2] - con_string = self.constraints.__repr__().split("\n", 2)[2] + grouped_names = self._piecewise_names() + var_string = self.variables._repr_filtered(grouped_names) + con_string = self.constraints._repr_filtered(grouped_names) model_string = f"Linopy {self.type} model" - return ( + result = ( f"{model_string}\n{'=' * len(model_string)}\n\n" f"Variables:\n----------\n{var_string}\n" - f"Constraints:\n------------\n{con_string}\n" - f"Status:\n-------\n{self.status}" + f"Constraints:\n------------\n{con_string}" ) + if self._piecewise_formulations: + result += "\nPiecewise Formulations:\n----------------------\n" + for pwl in self._piecewise_formulations.values(): + n_vars = len(pwl.variables) + n_cons = len(pwl.constraints) + # Collect user-facing dims (skip internal _ prefixed dims) + user_dims: list[str] = [] + for var in pwl.variables.data.values(): + for d in var.coords: + if not str(d).startswith("_") and str(d) not in user_dims: + user_dims.append(str(d)) + dims_str = f" ({', '.join(user_dims)})" if user_dims else "" + result += ( + f" * {pwl.name}{dims_str}" + f" — {pwl.method}, {n_vars} vars, {n_cons} cons\n" + ) + + result += f"\nStatus:\n-------\n{self.status}" + return result + + def _piecewise_names(self) -> set[str]: + """Return all variable/constraint names belonging to piecewise formulations.""" + names: set[str] = set() + for pwl in self._piecewise_formulations.values(): + names.update(pwl.variable_names) + names.update(pwl.constraint_names) + return names + def __getitem__(self, key: str) -> Variable: """ Get a model variable by the name. @@ -791,7 +824,7 @@ def add_sos_constraints( variable.attrs.update(attrs_update) - add_piecewise_constraints = add_piecewise_constraints + add_piecewise_formulation = add_piecewise_formulation def add_constraints( self, diff --git a/linopy/piecewise.py b/linopy/piecewise.py index e06664b3..a0e2a5ba 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -21,24 +21,25 @@ HELPER_DIMS, LP_SEG_DIM, PWL_ACTIVE_BOUND_SUFFIX, - PWL_BINARY_SUFFIX, + PWL_BINARY_ORDER_SUFFIX, PWL_CONVEX_SUFFIX, + PWL_DELTA_BOUND_SUFFIX, PWL_DELTA_SUFFIX, - PWL_FILL_SUFFIX, - PWL_INC_BINARY_SUFFIX, - PWL_INC_LINK_SUFFIX, - PWL_INC_ORDER_SUFFIX, + PWL_FILL_ORDER_SUFFIX, PWL_LAMBDA_SUFFIX, + PWL_LINK_SUFFIX, + PWL_ORDER_BINARY_SUFFIX, + PWL_SEGMENT_BINARY_SUFFIX, PWL_SELECT_SUFFIX, - PWL_X_LINK_SUFFIX, SEGMENT_DIM, ) if TYPE_CHECKING: - from linopy.constraints import Constraint + from linopy.constraints import Constraint, Constraints from linopy.expressions import LinearExpression from linopy.model import Model from linopy.types import LinExprLike + from linopy.variables import Variables # Accepted input types for breakpoint-like data BreaksLike: TypeAlias = ( @@ -54,6 +55,70 @@ ) +# --------------------------------------------------------------------------- +# Result type +# --------------------------------------------------------------------------- + + +class PiecewiseFormulation: + """ + Result of ``add_piecewise_formulation``. + + Groups all auxiliary variables and constraints created by a single + piecewise formulation. Stores only names internally; ``variables`` + and ``constraints`` properties return live views from the model. + """ + + __slots__ = ("name", "method", "variable_names", "constraint_names", "_model") + + def __init__( + self, + name: str, + method: str, + variable_names: list[str], + constraint_names: list[str], + model: Model, + ) -> None: + self.name = name + self.method = method + self.variable_names = variable_names + self.constraint_names = constraint_names + self._model = model + + @property + def variables(self) -> Variables: + """View of the auxiliary variables in this formulation.""" + return self._model.variables[self.variable_names] + + @property + def constraints(self) -> Constraints: + """View of the auxiliary constraints in this formulation.""" + return self._model.constraints[self.constraint_names] + + def __repr__(self) -> str: + # Collect user-facing dims with sizes (skip internal _ prefixed dims) + user_dims: dict[str, int] = {} + for var in self.variables.data.values(): + for d in var.coords: + ds = str(d) + if not ds.startswith("_") and ds not in user_dims: + user_dims[ds] = var.data.sizes[d] + dims_str = ", ".join(f"{d}: {s}" for d, s in user_dims.items()) + header = f"PiecewiseFormulation `{self.name}`" + if dims_str: + header += f" [{dims_str}]" + r = f"{header} — {self.method}\n" + r += " Variables:\n" + for vname, var in self.variables.items(): + dims = ", ".join(str(d) for d in var.coords) if var.coords else "" + r += f" * {vname} ({dims})\n" if dims else f" * {vname}\n" + r += " Constraints:\n" + for cname, con in self.constraints.items(): + dims = ", ".join(str(d) for d in con.coords) if con.coords else "" + r += f" * {cname} ({dims})\n" if dims else f" * {cname}\n" + return r + + # --------------------------------------------------------------------------- # DataArray construction helpers # --------------------------------------------------------------------------- @@ -559,13 +624,13 @@ def _broadcast_points( # --------------------------------------------------------------------------- -def add_piecewise_constraints( +def add_piecewise_formulation( model: Model, *pairs: tuple[LinExprLike, BreaksLike], method: Literal["sos2", "incremental", "auto"] = "auto", active: LinExprLike | None = None, name: str | None = None, -) -> Constraint: +) -> PiecewiseFormulation: r""" Add piecewise linear equality constraints. @@ -576,14 +641,14 @@ def add_piecewise_constraints( Example — 2 variables:: - m.add_piecewise_constraints( + m.add_piecewise_formulation( (power, [0, 30, 60, 100]), (fuel, [0, 36, 84, 170]), ) Example — 3 variables (CHP plant):: - m.add_piecewise_constraints( + m.add_piecewise_formulation( (power, [0, 30, 60, 100]), (fuel, [0, 40, 85, 160]), (heat, [0, 25, 55, 95]), @@ -609,7 +674,7 @@ def add_piecewise_constraints( Returns ------- - Constraint + PiecewiseFormulation """ if method not in ("sos2", "incremental", "auto"): raise ValueError( @@ -618,7 +683,7 @@ def add_piecewise_constraints( if len(pairs) < 2: raise TypeError( - "add_piecewise_constraints() requires at least 2 " + "add_piecewise_formulation() requires at least 2 " "(expression, breakpoints) pairs." ) @@ -680,32 +745,51 @@ def add_piecewise_constraints( lin_exprs = [_to_linexpr(expr) for expr in all_exprs] active_expr = _to_linexpr(active) if active is not None else None + # Snapshot existing names to detect what the formulation adds + vars_before = set(model.variables) + cons_before = set(model.constraints) + if disjunctive: if method == "incremental": raise ValueError( "Incremental method is not supported for disjunctive constraints" ) - return _add_disjunctive( + _add_disjunctive( + model, + name, + lin_exprs, + bp_list, + link_coords, + bp_mask, + active_expr, + ) + resolved_method = "sos2" + else: + # Continuous: stack into N-variable formulation + resolved_method = _add_continuous( model, name, lin_exprs, bp_list, link_coords, bp_mask, + method, active_expr, ) - # Continuous: stack into N-variable formulation - return _add_continuous( - model, - name, - lin_exprs, - bp_list, - link_coords, - bp_mask, - method, - active_expr, + # Collect newly created variable and constraint names + new_vars = [n for n in model.variables if n not in vars_before] + new_cons = [n for n in model.constraints if n not in cons_before] + + result = PiecewiseFormulation( + name=name, + method=resolved_method, + variable_names=new_vars, + constraint_names=new_cons, + model=model, ) + model._piecewise_formulations[name] = result + return result def _stack_along_link( @@ -729,8 +813,12 @@ def _add_continuous( bp_mask: DataArray | None, method: str, active: LinearExpression | None = None, -) -> Constraint: - """Dispatch continuous piecewise equality to SOS2 or incremental.""" +) -> str: + """ + Dispatch continuous piecewise equality to SOS2 or incremental. + + Returns the resolved method name ("sos2" or "incremental"). + """ from linopy.expressions import LinearExpression link_dim = "_pwl_var" @@ -774,7 +862,7 @@ def _add_continuous( rhs = active if active is not None else 1 if method == "sos2": - return _add_sos2( + _add_sos2( model, name, target_expr, @@ -783,8 +871,9 @@ def _add_continuous( link_dim, rhs, ) + return method else: - return _add_incremental( + _add_incremental( model, name, target_expr, @@ -794,6 +883,7 @@ def _add_continuous( rhs, active, ) + return method def _add_sos2( @@ -813,7 +903,7 @@ def _add_sos2( lambda_name = f"{name}{PWL_LAMBDA_SUFFIX}" convex_name = f"{name}{PWL_CONVEX_SUFFIX}" - link_name = f"{name}{PWL_X_LINK_SUFFIX}" + link_name = f"{name}{PWL_LINK_SUFFIX}" lambda_var = model.add_variables( lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=lambda_mask @@ -840,11 +930,11 @@ def _add_incremental( 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}" + fill_order_name = f"{name}{PWL_FILL_ORDER_SUFFIX}" + link_name = f"{name}{PWL_LINK_SUFFIX}" + order_binary_name = f"{name}{PWL_ORDER_BINARY_SUFFIX}" + delta_bound_name = f"{name}{PWL_DELTA_BOUND_SUFFIX}" + binary_order_name = f"{name}{PWL_BINARY_ORDER_SUFFIX}" n_segments = stacked_bp.sizes[dim] - 1 seg_dim = f"{dim}_seg" @@ -873,17 +963,17 @@ def _add_incremental( 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 + binary=True, coords=delta_coords, name=order_binary_name, mask=delta_mask ) - model.add_constraints(delta_var <= binary_var, name=inc_link_name) + model.add_constraints(delta_var <= binary_var, name=delta_bound_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) + model.add_constraints(delta_hi <= delta_lo, name=fill_order_name) binary_hi = binary_var.isel({seg_dim: slice(1, None)}, drop=True) - model.add_constraints(binary_hi <= delta_lo, name=inc_order_name) + model.add_constraints(binary_hi <= delta_lo, name=binary_order_name) bp0 = stacked_bp.isel({dim: 0}) bp0_term: DataArray | LinearExpression = bp0 @@ -945,11 +1035,11 @@ def _add_disjunctive( lambda_mask = agg_mask binary_mask = agg_mask.any(dim=dim) - binary_name = f"{name}{PWL_BINARY_SUFFIX}" + binary_name = f"{name}{PWL_SEGMENT_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}" + link_name = f"{name}{PWL_LINK_SUFFIX}" binary_var = model.add_variables( binary=True, coords=binary_coords, name=binary_name, mask=binary_mask diff --git a/linopy/variables.py b/linopy/variables.py index 0dfca099..15874029 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -1485,15 +1485,14 @@ def __dir__(self) -> list[str]: ] return base_attributes + formatted_names - def __repr__(self) -> str: - """ - Return a string representation of the linopy model. - """ - r = "linopy.model.Variables" - line = "-" * len(r) - r += f"\n{line}\n" - + def _format_items(self, exclude: set[str] | None = None) -> str: + """Format variable items, optionally excluding names in a group.""" + r = "" + count = 0 for name, ds in self.items(): + if exclude and name in exclude: + continue + count += 1 coords = ( " (" + ", ".join(str(coord) for coord in ds.coords) + ")" if ds.coords @@ -1506,10 +1505,21 @@ def __repr__(self) -> str: if ds.attrs.get("semi_continuous", False): coords += " - semi-continuous" r += f" * {name}{coords}\n" - if not len(list(self)): + if count == 0: r += "\n" return r + def __repr__(self) -> str: + r = "linopy.model.Variables" + line = "-" * len(r) + r += f"\n{line}\n" + r += self._format_items() + return r + + def _repr_filtered(self, exclude: set[str]) -> str: + """Format items excluding grouped names (used by Model.__repr__).""" + return self._format_items(exclude) + def __len__(self) -> int: return self.data.__len__() diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index dbc038fd..b837d1a5 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -21,16 +21,16 @@ BREAKPOINT_DIM, LP_SEG_DIM, PWL_ACTIVE_BOUND_SUFFIX, - PWL_BINARY_SUFFIX, + PWL_BINARY_ORDER_SUFFIX, PWL_CONVEX_SUFFIX, + PWL_DELTA_BOUND_SUFFIX, PWL_DELTA_SUFFIX, - PWL_FILL_SUFFIX, - PWL_INC_BINARY_SUFFIX, - PWL_INC_LINK_SUFFIX, - PWL_INC_ORDER_SUFFIX, + PWL_FILL_ORDER_SUFFIX, PWL_LAMBDA_SUFFIX, + PWL_LINK_SUFFIX, + PWL_ORDER_BINARY_SUFFIX, + PWL_SEGMENT_BINARY_SUFFIX, PWL_SELECT_SUFFIX, - PWL_X_LINK_SUFFIX, SEGMENT_DIM, ) from linopy.solver_capabilities import SolverFeature, get_available_solvers_with_feature @@ -282,14 +282,14 @@ def test_sos2(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (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_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 @@ -299,7 +299,7 @@ def test_auto_selects_incremental_for_monotonic(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") # Both breakpoint sequences must be monotonic for incremental - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [0, 10, 50, 100]), (y, [0, 5, 20, 80]), ) @@ -311,7 +311,7 @@ def test_auto_nonmonotonic_falls_back_to_sos2(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") # Non-monotonic y-breakpoints force SOS2 - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [0, 50, 30, 100]), (y, [5, 20, 15, 80]), ) @@ -323,7 +323,7 @@ def test_multi_dimensional(self) -> None: gens = pd.Index(["gen_a", "gen_b"], name="generator") x = m.add_variables(coords=[gens], name="x") y = m.add_variables(coords=[gens], name="y") - m.add_piecewise_constraints( + m.add_piecewise_formulation( ( x, breakpoints( @@ -346,7 +346,7 @@ def test_with_slopes(self) -> None: 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( + m.add_piecewise_formulation( (x, [0, 10, 50, 100]), (y, breakpoints(slopes=[-0.3, 0.45, 1.2], x_points=[0, 10, 50, 100], y0=5)), ) @@ -422,7 +422,7 @@ def test_creates_delta_vars(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [0, 10, 50, 100]), (y, [5, 10, 20, 80]), method="incremental", @@ -430,7 +430,7 @@ def test_creates_delta_vars(self) -> None: assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"] assert delta.labels.sizes[LP_SEG_DIM] == 3 - assert f"pwl0{PWL_FILL_SUFFIX}" in m.constraints + assert f"pwl0{PWL_FILL_ORDER_SUFFIX}" in m.constraints assert f"pwl0{PWL_LAMBDA_SUFFIX}" not in m.variables def test_nonmonotonic_raises(self) -> None: @@ -438,7 +438,7 @@ def test_nonmonotonic_raises(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") with pytest.raises(ValueError, match="strictly monotonic"): - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [0, 50, 30, 100]), (y, [5, 20, 15, 80]), method="incremental", @@ -448,7 +448,7 @@ def test_sos2_nonmonotonic_succeeds(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [0, 50, 30, 100]), (y, [5, 20, 15, 80]), method="sos2", @@ -460,60 +460,60 @@ def test_two_breakpoints_no_fill(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (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_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( + m.add_piecewise_formulation( (x, [0, 10, 50, 100]), (y, [5, 10, 20, 80]), method="incremental", ) - assert f"pwl0{PWL_INC_BINARY_SUFFIX}" in m.variables - binary = m.variables[f"pwl0{PWL_INC_BINARY_SUFFIX}"] + assert f"pwl0{PWL_ORDER_BINARY_SUFFIX}" in m.variables + binary = m.variables[f"pwl0{PWL_ORDER_BINARY_SUFFIX}"] assert binary.labels.sizes[LP_SEG_DIM] == 3 - assert f"pwl0{PWL_INC_LINK_SUFFIX}" in m.constraints + assert f"pwl0{PWL_DELTA_BOUND_SUFFIX}" in m.constraints def test_creates_order_constraints(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [0, 10, 50, 100]), (y, [5, 10, 20, 80]), method="incremental", ) - assert f"pwl0{PWL_INC_ORDER_SUFFIX}" in m.constraints + assert f"pwl0{PWL_BINARY_ORDER_SUFFIX}" in m.constraints def test_two_breakpoints_no_order_constraint(self) -> None: """With only one segment, there's no order constraint needed.""" m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [0, 100]), (y, [5, 80]), method="incremental", ) - assert f"pwl0{PWL_INC_BINARY_SUFFIX}" in m.variables - assert f"pwl0{PWL_INC_LINK_SUFFIX}" in m.constraints - assert f"pwl0{PWL_INC_ORDER_SUFFIX}" not in m.constraints + assert f"pwl0{PWL_ORDER_BINARY_SUFFIX}" in m.variables + assert f"pwl0{PWL_DELTA_BOUND_SUFFIX}" in m.constraints + assert f"pwl0{PWL_BINARY_ORDER_SUFFIX}" not in m.constraints def test_decreasing_monotonic(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [100, 50, 10, 0]), (y, [80, 20, 5, 2]), method="incremental", @@ -531,11 +531,11 @@ def test_equality_creates_binary(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (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_SEGMENT_BINARY_SUFFIX}" in m.variables assert f"pwl0{PWL_SELECT_SUFFIX}" in m.constraints assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables assert f"pwl0{PWL_CONVEX_SUFFIX}" in m.constraints @@ -547,7 +547,7 @@ def test_method_incremental_raises(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") with pytest.raises(ValueError, match="disjunctive"): - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, segments([[0, 10], [50, 100]])), (y, segments([[0, 5], [20, 80]])), method="incremental", @@ -558,7 +558,7 @@ def test_multi_dimensional(self) -> None: gens = pd.Index(["gen_a", "gen_b"], name="generator") x = m.add_variables(coords=[gens], name="x") y = m.add_variables(coords=[gens], name="y") - m.add_piecewise_constraints( + m.add_piecewise_formulation( ( x, segments( @@ -574,7 +574,7 @@ def test_multi_dimensional(self) -> None: ), ), ) - binary = m.variables[f"pwl0{PWL_BINARY_SUFFIX}"] + binary = m.variables[f"pwl0{PWL_SEGMENT_BINARY_SUFFIX}"] lam = m.variables[f"pwl0{PWL_LAMBDA_SUFFIX}"] assert "generator" in binary.dims assert "generator" in lam.dims @@ -585,15 +585,15 @@ def test_three_variables(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") z = m.add_variables(name="z") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (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_SEGMENT_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}"] + link = m.constraints[f"pwl0{PWL_LINK_SUFFIX}"] assert "_pwl_var" in [str(d) for d in link.dims] @@ -607,14 +607,14 @@ def test_wrong_arg_types_raises(self) -> None: m = Model() x = m.add_variables(name="x") with pytest.raises(TypeError, match="at least 2"): - m.add_piecewise_constraints((x, [0, 10, 50])) + m.add_piecewise_formulation((x, [0, 10, 50])) def test_invalid_method_raises(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") with pytest.raises(ValueError, match="method must be"): - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [0, 10, 50]), (y, [5, 10, 20]), method="invalid", # type: ignore @@ -625,7 +625,7 @@ def test_mismatched_breakpoint_sizes_raises(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") with pytest.raises(ValueError, match="same size"): - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [0, 10, 50]), (y, [5, 10]), ) @@ -634,7 +634,7 @@ 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 + m.add_piecewise_formulation(x, [0, 10, 50]) # type: ignore # =========================================================================== @@ -648,8 +648,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, [0, 10, 50]), (y, [5, 10, 20])) - m.add_piecewise_constraints((x, [0, 20, 80]), (z, [10, 15, 50])) + m.add_piecewise_formulation((x, [0, 10, 50]), (y, [5, 10, 20])) + m.add_piecewise_formulation((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 @@ -657,13 +657,13 @@ def test_custom_name(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (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_LINK_SUFFIX}" in m.constraints # N-var path uses a single stacked link constraint (no separate y_link) @@ -680,7 +680,7 @@ def test_broadcast_over_extra_dims(self) -> None: 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 - m.add_piecewise_constraints( + m.add_piecewise_formulation( ( x, breakpoints( @@ -712,7 +712,7 @@ def test_nan_masks_lambda_labels(self) -> None: 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]) - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, x_pts), (y, y_pts), method="sos2", @@ -730,7 +730,7 @@ def test_sos2_interior_nan_raises(self) -> None: x_pts = xr.DataArray([0, np.nan, 50, 100], dims=[BREAKPOINT_DIM]) y_pts = xr.DataArray([0, np.nan, 20, 40], dims=[BREAKPOINT_DIM]) with pytest.raises(ValueError, match="non-trailing NaN"): - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, x_pts), (y, y_pts), method="sos2", @@ -747,7 +747,7 @@ def test_sos2_equality(self, tmp_path: Path) -> None: m = Model() x = m.add_variables(name="x", lower=0, upper=100) y = m.add_variables(name="y") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [0.0, 10.0, 50.0, 100.0]), (y, [5.0, 2.0, 20.0, 80.0]), method="sos2", @@ -763,7 +763,7 @@ def test_disjunctive_sos2_and_binary(self, tmp_path: Path) -> None: m = Model() x = m.add_variables(name="x", lower=0, upper=100) y = m.add_variables(name="y") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, segments([[0.0, 10.0], [50.0, 100.0]])), (y, segments([[0.0, 5.0], [20.0, 80.0]])), ) @@ -790,7 +790,7 @@ def test_equality_minimize_cost(self, solver_name: str) -> None: m = Model() x = m.add_variables(lower=0, upper=100, name="x") cost = m.add_variables(name="cost") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [0, 50, 100]), (cost, [0, 10, 50]), ) @@ -805,7 +805,7 @@ def test_equality_maximize_efficiency(self, solver_name: str) -> None: m = Model() power = m.add_variables(lower=0, upper=100, name="power") eff = m.add_variables(name="eff") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (power, [0, 25, 50, 75, 100]), (eff, [0.7, 0.85, 0.95, 0.9, 0.8]), ) @@ -819,7 +819,7 @@ def test_disjunctive_solve(self, solver_name: str) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, segments([[0.0, 10.0], [50.0, 100.0]])), (y, segments([[0.0, 5.0], [20.0, 80.0]])), ) @@ -924,7 +924,7 @@ def test_incremental_creates_active_bound(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [0, 10, 50, 100]), (y, [5, 10, 20, 80]), active=u, @@ -938,7 +938,7 @@ def test_active_none_is_default(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [0, 10, 50]), (y, [0, 5, 30]), method="incremental", @@ -951,7 +951,7 @@ def test_active_with_linear_expression(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") u = m.add_variables(binary=True, name="u") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [0, 50, 100]), (y, [0, 10, 50]), active=1 * u, @@ -977,7 +977,7 @@ def test_incremental_active_on(self, solver_name: str) -> None: 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( + m.add_piecewise_formulation( (x, [0, 50, 100]), (y, [0, 10, 50]), active=u, @@ -997,7 +997,7 @@ def test_incremental_active_off(self, solver_name: str) -> None: 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( + m.add_piecewise_formulation( (x, [0, 50, 100]), (y, [0, 10, 50]), active=u, @@ -1021,7 +1021,7 @@ def test_incremental_nonzero_base_active_off(self, solver_name: str) -> None: 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( + m.add_piecewise_formulation( (x, [20, 60, 100]), (y, [5, 20, 50]), active=u, @@ -1044,7 +1044,7 @@ def test_unit_commitment_pattern(self, solver_name: str) -> None: fuel = m.add_variables(name="fuel") u = m.add_variables(binary=True, name="commit") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (power, [p_min, p_max]), (fuel, [fuel_at_pmin, fuel_at_pmax]), active=u, @@ -1067,7 +1067,7 @@ def test_multi_dimensional_solver(self, solver_name: str) -> None: x = m.add_variables(lower=0, upper=100, coords=[gens], name="x") y = m.add_variables(coords=[gens], name="y") u = m.add_variables(binary=True, coords=[gens], name="u") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, [0, 50, 100]), (y, [0, 10, 50]), active=u, @@ -1097,7 +1097,7 @@ def test_sos2_active_off(self, solver_name: str) -> None: 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( + m.add_piecewise_formulation( (x, [0, 50, 100]), (y, [0, 10, 50]), active=u, @@ -1116,7 +1116,7 @@ def test_disjunctive_active_off(self, solver_name: str) -> None: 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( + m.add_piecewise_formulation( (x, segments([[0.0, 10.0], [50.0, 100.0]])), (y, segments([[0.0, 5.0], [20.0, 80.0]])), active=u, @@ -1141,32 +1141,32 @@ 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") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (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 assert f"pwl0{PWL_CONVEX_SUFFIX}" in m.constraints - assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints + assert f"pwl0{PWL_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") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (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 - assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints + assert f"pwl0{PWL_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") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (power, [0.0, 50.0, 100.0]), (fuel, [0.0, 20.0, 60.0]), ) @@ -1177,7 +1177,7 @@ def test_single_pair_raises(self) -> None: m = Model() power = m.add_variables(name="power") with pytest.raises(TypeError, match="at least 2"): - m.add_piecewise_constraints( + m.add_piecewise_formulation( (power, [0.0, 50.0, 100.0]), ) @@ -1186,23 +1186,23 @@ 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") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (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 + assert f"pwl0{PWL_LINK_SUFFIX}" in m.constraints # link constraint should have _pwl_var dimension - link = m.constraints[f"pwl0{PWL_X_LINK_SUFFIX}"] + link = m.constraints[f"pwl0{PWL_LINK_SUFFIX}"] 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") - m.add_piecewise_constraints( + m.add_piecewise_formulation( (power, [0.0, 50.0, 100.0]), (fuel, [0.0, 20.0, 60.0]), name="chp", @@ -1269,7 +1269,7 @@ def test_non_numeric_breakpoint_coords_raises(self) -> None: coords={BREAKPOINT_DIM: ["a", "b", "c"]}, ) with pytest.raises(ValueError, match="numeric coordinates"): - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, x_pts), (y, y_pts), method="sos2", @@ -1283,7 +1283,7 @@ def test_missing_breakpoint_dim_on_second_arg_raises(self) -> None: 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)) + m.add_piecewise_formulation((x, good), (y, bad)) def test_segment_dim_mismatch_raises(self) -> None: """Segment dim on only one breakpoint array raises.""" @@ -1293,7 +1293,7 @@ def test_segment_dim_mismatch_raises(self) -> None: 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)) + m.add_piecewise_formulation((x, x_pts), (y, y_pts)) def test_disjunctive_three_pairs(self) -> None: """Disjunctive with 3 pairs works (N-variable).""" @@ -1302,14 +1302,14 @@ def test_disjunctive_three_pairs(self) -> None: y = m.add_variables(name="y") z = m.add_variables(name="z") seg = segments([[0, 10], [50, 100]]) - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, seg), (y, seg), (z, seg), ) - assert f"pwl0{PWL_BINARY_SUFFIX}" in m.variables + assert f"pwl0{PWL_SEGMENT_BINARY_SUFFIX}" in m.variables assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables - assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints + assert f"pwl0{PWL_LINK_SUFFIX}" in m.constraints def test_disjunctive_interior_nan_raises(self) -> None: """Disjunctive with interior NaN raises ValueError.""" @@ -1326,7 +1326,7 @@ def test_disjunctive_interior_nan_raises(self) -> None: dims=[SEGMENT_DIM, BREAKPOINT_DIM], ) with pytest.raises(ValueError, match="non-trailing NaN"): - m.add_piecewise_constraints((x, x_pts), (y, y_pts)) + m.add_piecewise_formulation((x, x_pts), (y, y_pts)) def test_expression_name_fallback(self) -> None: """LinExpr (not Variable) gets numeric name in link coords.""" @@ -1334,7 +1334,7 @@ def test_expression_name_fallback(self) -> None: 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( + m.add_piecewise_formulation( (1.0 * x, [0, 50, 10]), (1.0 * y, [0, 20, 5]), method="sos2", @@ -1349,7 +1349,7 @@ def test_incremental_with_nan_mask(self) -> None: 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( + m.add_piecewise_formulation( (x, x_pts), (y, y_pts), method="incremental", @@ -1364,7 +1364,7 @@ def test_scalar_coord_dropped(self) -> None: y = m.add_variables(name="y") bp = breakpoints([0, 10, 50]) bp_with_scalar = bp.assign_coords(extra=42) - m.add_piecewise_constraints( + m.add_piecewise_formulation( (x, bp_with_scalar), (y, [0, 5, 20]), method="sos2", From f0267f53489e2e37731471aef3be6ca1e508fb18 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:47:33 +0200 Subject: [PATCH 32/65] fix: remove unused type: ignore on merge() cls assignment Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/expressions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linopy/expressions.py b/linopy/expressions.py index f7c44b85..49ab93bb 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -2356,7 +2356,7 @@ def merge( has_quad_expression = any(isinstance(e, QuadraticExpression) for e in exprs) has_linear_expression = any(isinstance(e, LinearExpression) for e in exprs) if cls is None: - cls = QuadraticExpression if has_quad_expression else LinearExpression # type: ignore + cls = QuadraticExpression if has_quad_expression else LinearExpression if ( issubclass(cls, QuadraticExpression) From faa4b01c61424e41e093855fe585a8bacdfc4982 Mon Sep 17 00:00:00 2001 From: Felix <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:19:42 +0200 Subject: [PATCH 33/65] feat(piecewise): add sign parameter and LP method to add_piecewise_formulation (#663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add sign parameter and LP method to add_piecewise_formulation Introduces a sign parameter ("==", "<=", ">=") with a first-tuple convention: the first tuple's expression is the signed output; all remaining tuples are treated as inputs forced to equality. A new method="lp" uses pure tangent lines (no aux variables) for 2-variable inequality cases on convex/concave curves. method="auto" automatically dispatches to LP when applicable, otherwise falls back to SOS2/incremental with the sign applied to the output link. Internally: - sign="==" keeps a single stacked link (unchanged behaviour) - sign!="==" splits: one stacked equality link for inputs plus one output link carrying the sign - LP adds per-segment chord constraints plus domain bounds on x Uses the existing SIGNS / EQUAL / LESS_EQUAL / GREATER_EQUAL constants from linopy.constants for validation and dispatch. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: add piecewise inequality notebook and update release notes New examples/piecewise-inequality-bounds.ipynb walks through the sign parameter, the first-tuple convention, and the LP/SOS2/incremental equivalence within the x-domain. Includes a feasibility region plot and demonstrates auto-dispatch + non-convex fallback. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: add 3D feasibility ribbon and mathematical formulation Adds the full mathematical formulation (equality, inequality, LP, incremental) as a dedicated markdown section, and a 3D Poly3DCollection plot showing the feasible ribbon for 3-variable sign='<=' — a 1-D curve in 3-D space extruded downward in the output axis. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: show 3D feasibility ribbon from multiple viewpoints Keeps matplotlib (consistent with other notebooks, no new deps) but renders the 3D ribbon in three side-by-side projections: perspective, (power, fuel) side view, (power, heat) top view. Easier to read than a single 3D plot in a static doc. Co-Authored-By: Claude Opus 4.6 (1M context) * docs,fix: clarify that mismatched curvature+sign is wrong, not just loose For concave+">=" or convex+"<=", tangent lines give a feasible region that is a strict subset of the true hypograph/epigraph — rejecting points that satisfy the true constraint. This is wrong, not merely a loose relaxation. - Update error message in method="lp" to make this explicit - Correct the convexity×sign table in the notebook to mark the ✗ cases as "wrong region", not "loose" - Add tests covering concave+">=" and convex+"<=" auto-fallback + explicit lp raise Co-Authored-By: Claude Opus 4.6 (1M context) * refac: make LP error messages terse Error messages should state the problem and point to a fix, not teach the theory. The detailed convexity × sign semantics live in the notebook/docs, not in runtime errors. Also removes the "strict subset" claim, which was true in common cases but not watertight at domain boundaries. 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 * feat: log resolved method when method='auto' Users who care which formulation they got (e.g. LP vs MIP for performance) can see the dispatch decision in the normal log output without checking PiecewiseFormulation.method manually. Example: INFO linopy.piecewise: piecewise formulation 'pwl0': auto selected method='lp' (sign='<=', 2 pairs) Logged at info level, only when method='auto' (explicit choices are not logged — the user already knows). Co-Authored-By: Claude Opus 4.6 (1M context) * feat: log when LP is skipped and why When method='auto' and the inequality case can't use LP (wrong number of tuples, non-monotonic x, mismatched curvature, active=...), log an info-level message explaining why before falling back to SOS2/incremental. Example: INFO linopy.piecewise: piecewise formulation 'pwl0': LP not applicable (sign='<=' needs concave/linear curvature, got 'convex'); will use SOS2/incremental instead Factored the LP-eligibility check into a new _lp_eligibility helper that returns (ok, reason) — used by auto dispatch to decide + log. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: expose convexity on PiecewiseFormulation Adds a ``convexity`` attribute ({"convex", "concave", "linear", "mixed"} or None) set automatically when the shape is well-defined (exactly two tuples, non-disjunctive, strictly monotonic x). Widens two helper signatures to ``LinearExpression | None`` / ``DataArray | None`` to match their actual usage. Adds PWL_METHODS and PWL_CONVEXITIES sets to back the runtime validation; the user-facing ``Literal[...]`` hints remain the static source of truth. Co-Authored-By: Claude Opus 4.7 (1M context) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: make convexity detection invariant to x-direction _detect_convexity previously treated a concave curve with decreasing x as convex (and vice-versa), because the slope sequence appears reversed when x descends. As a result, method="auto" could dispatch LP on a curvature+sign combination the implementation explicitly documents as "wrong region", and explicit method="lp" would accept the same case. Sort each entity's breakpoints by x ascending before classifying. Adds two regression tests covering auto-dispatch and explicit LP. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: mask trailing-NaN segments in LP path _add_lp built one chord constraint per breakpoint segment without honouring the breakpoint mask. For per-entity inputs where some entities have fewer breakpoints (NaN tail), the NaN slope/intercept became 0 in the constraint, producing a spurious ``y ≤ 0`` for the padded segments and forcing the output to zero. Compute a per-segment validity mask (both endpoints non-NaN) and pass it through to the chord constraint via ``_add_signed_link``. Also delegates the tangent-line construction to the existing public ``tangent_lines`` helper to remove the duplicated slope/intercept math. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: correct sign param — applies to first tuple, not last The Parameters block contradicted the prose and the implementation, which use the first-tuple convention. Co-Authored-By: Claude Opus 4.7 (1M context) * refac,test: simplify _detect_convexity and add direct unit tests Collapse the per-slice numpy loop into an xarray-native classifier: NaN propagation through .diff() handles masked breakpoints, and multiplying the second-slope-difference by ``sign(dx.sum(...))`` keeps the ascending/descending-x invariance from the previous fix. Scope is deliberately single-curve; multi-entity inputs aggregate across entities. For N>2 variables (not supported by LP today) the right shape is a single-pair classifier plus a combinator at the call site — left for when the LP path generalizes. Adds TestDetectConvexity covering: basic convex/concave/linear/mixed, floating-point tolerance, too-few-points, ascending-vs-descending invariance, trailing-NaN padding, multi-entity same-shape, multi-entity mixed direction, multi-entity mixed curvature. Co-Authored-By: Claude Opus 4.7 (1M context) * docs,test: document active + non-equality sign asymmetry active=0 pins auxiliary variables to zero, which under sign="==" forces the output to 0 exactly. Under sign="<=" or ">=" it only pushes the signed bound to 0 — the complementary side still falls back to the output variable's own upper/lower bound, which is often not what a reader expects from a "deactivated" unit. Call out the asymmetry in the ``active`` docstring and add a regression test that pins the current behaviour (minimising y under active=0 + sign="<=" goes to the variable's lb, not 0). A future change to auto-couple the complementary bound should flip that test. Co-Authored-By: Claude Opus 4.7 (1M context) * test: extend active + sign='<=' coverage to incremental and disjunctive Parametrise the SOS2 regression over incremental as well, and add a matching test for the disjunctive (segments) path. All three methods show the same asymmetry: input pinned to 0 via the equality input link, output only signed-bounded. Co-Authored-By: Claude Opus 4.7 (1M context) * docs,test: show the y.lower=0 recipe for active + non-equality sign Make the docstring note actionable: the usual fuel/cost/heat outputs are naturally non-negative, so setting lower=0 on the output turns the documented sign="<=" + active=0 asymmetry into a non-issue (the variable bound combined with y ≤ 0 forces y = 0 automatically). Genuinely signed outputs still need the big-M coupling called out. Pins the recipe down with a test that maximises y under active=0 and asserts y = 0. Co-Authored-By: Claude Opus 4.7 (1M context) * test: add 7 regression tests for review-flagged coverage gaps - method='lp' + active raises (silent would produce wrong model) - LP accepts a linear curve (convexity='linear', either sign) - method='auto' emits an INFO log when it skips LP - LP domain bound is enforced (x > x_max → infeasible) - LP matches SOS2 on multi-dim (entity) variables - LP vs SOS2 consistency on both sides of y ≤ f(x) - Disjunctive + sign='<=' is respected by the solver Placed in TestSignParameter (LP/sign behaviour) and TestDisjunctive (disjunctive solver) rather than a separate review-named bucket. Co-Authored-By: Claude Opus 4.7 (1M context) * refac: package PWL links in a dataclass and flatten auto-dispatch Addresses review issues 8, 9, 10: - Introduces ``_PwlLinks``, a single dataclass carrying the stacked-for-lambda breakpoints plus the equality- and signed-side link expressions the three builders need. The EQUAL / non-EQUAL split lives in one place (``_build_links``) instead of being duplicated in ``_add_continuous`` and ``_add_disjunctive``. - ``_add_sos2``/``_add_incremental``/``_add_disjunctive`` drop from 9–11 parameters with correlated ``Optional`` pairs down to a short list taking the links struct. ``_add_incremental`` also loses its unused ``rhs`` parameter (incremental gates via ``delta <= active``, not via a convex-sum = rhs constraint). - ``_add_continuous`` becomes ~10 lines: it either dispatches LP via ``_try_lp`` (returns bool) or builds links and hands off to a single ``_resolve_sos2_vs_incremental`` helper before calling the chosen builder. No more 5-way ``method`` branching in one body. Behaviour is unchanged — same 147 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) * refac: rename PWL_LP_SUFFIX → PWL_CHORD_SUFFIX ``_lp`` echoed the method name without saying what the constraint does. The LP formulation adds one chord-line constraint per segment (``y <= m·x + c`` per breakpoint pair), so ``_chord`` describes the actual object being added and is independent of which method built it. Reviewer-suggested alternative; also matches the chord-of-a- piecewise-curve framing used in the notebook. Co-Authored-By: Claude Opus 4.7 (1M context) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: persist PiecewiseFormulation.convexity across netCDF round-trip to_netcdf was dropping the convexity field; reload defaulted it to None (e.g. concave → None). Include it in the JSON payload and pass it back to the constructor on read. Co-Authored-By: Claude Opus 4.7 (1M context) * test: regression for PiecewiseFormulation netCDF round-trip Compare all __slots__ (except the model back-reference) so the test auto-catches any future field the IO layer forgets to persist. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: rewrite piecewise reference + tutorial for the new sign/LP API Thorough pass over the user-facing piecewise docs to match the current API (sign, method="lp", first-tuple convention) rather than the pre-PR equality-only surface. doc/piecewise-linear-constraints.rst: - Quick Start now shows both equality and inequality forms. - API block updated: sign in the signature, method now lists "lp". - New top-level section "The sign parameter — equality vs inequality" covering the first-tuple convention, math for 2-var <=, hypograph/ epigraph framing, and when to reach for inequality (primarily to unlock the LP chord formulation). Spells out the equality-is-often- the-right-call recommendation when curvature doesn't match sign. - Formulation Methods gains a full "LP (chord-line) formulation" subsection with the per-segment chord math, domain bound and the curvature+sign matching rule. The auto-dispatch intro lists LP as the first branch. - Every other formulation (SOS2/incremental/disjunctive) gets a short note on how it handles sign != "==". - "Generated variables and constraints" rewritten with the current suffix names (_link, _output_link, _chord, _domain_lo/_hi, _order_binary, _delta_bound, _binary_order, _active_bound) grouped per method. - Active parameter gains a note on the non-equality sign asymmetry with a pointer to the lower=0 recipe. - tangent_lines demoted: no longer a top-level API section; one pointer lives under the LP formulation for manual-control use. - See Also now links the new inequality-bounds notebook. examples/piecewise-linear-constraints.ipynb: - Section 4 rewritten from "Tangent lines — Concave efficiency bound" to "Inequality bounds — sign='<=' on a concave curve". Shows the one-liner add_piecewise_formulation((fuel, y), (power, x), sign="<=") and prints the resolved method/convexity to make the auto-LP dispatch visible. Outro points to the dedicated inequality notebook rather than showing the low-level tangent_lines path. doc/index.rst + doc/piecewise-inequality-bounds-tutorial.nblink: - Register the existing examples/piecewise-inequality-bounds.ipynb as a Sphinx page under the User Guide toctree so it's discoverable from the docs nav. Co-Authored-By: Claude Opus 4.7 (1M context) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * docs: compact the main piecewise tutorial notebook Collapse the equality sections (SOS2 / incremental / disjunctive as separate walk-throughs of the same dispatch pattern) into a single getting-started + a method-comparison table + one disjunctive example. Factor the shared dispatch pattern out of each example — model construction, demand and objective follow the same shape in every section, so the "new" cell in each only shows the one feature being introduced. 47 cells → 20; no loss of coverage (all 8 features still demonstrated: basic equality, method selection, disjunctive, sign/LP, slopes, active, N-variable, per-entity). Plot helper slimmed down to a one-curve overlay used once in the intro; later sections rely on the solution DataFrame. Links to the inequality-bounds notebook placed in the relevant sections. Co-Authored-By: Claude Opus 4.7 (1M context) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * docs: split piecewise release notes + tidy tangent_lines docstring Round-2 review items that weren't already handled by earlier commits: - Split the single mega-bullet in release notes into five findable bullets: core add_piecewise_formulation API, sign / LP dispatch, active (unit commitment), .method/.convexity metadata, and tangent_lines as the low-level helper. Each of sign/LP/active/ convexity is now greppable. - tangent_lines docstring: relax "strictly increasing" to "strictly monotonic" (_detect_convexity is already direction-invariant and tangent_lines doesn't care either way), and open with a pointer to add_piecewise_formulation(sign="<=") as the preferred high-level path — tangent_lines is the low-level escape hatch. - One-line comment on _build_links explaining the intentional eq_bp/stacked_bp aliasing in the sign="==" branch. The other round-2 items (stale RST, netCDF convexity persistence) are already handled by earlier commits fbc90d4 and 3dc1c6c/5889d04 — the reviewer was working against an older snapshot. Co-Authored-By: Claude Opus 4.7 (1M context) --------- 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/index.rst | 1 + ...iecewise-inequality-bounds-tutorial.nblink | 3 + doc/piecewise-linear-constraints.rst | 337 +- doc/release_notes.rst | 7 +- examples/piecewise-inequality-bounds.ipynb | 521 +++ examples/piecewise-linear-constraints.ipynb | 2944 +---------------- linopy/constants.py | 7 + linopy/io.py | 2 + linopy/piecewise.py | 738 ++++- test/test_piecewise_constraints.py | 644 ++++ 10 files changed, 2129 insertions(+), 3075 deletions(-) create mode 100644 doc/piecewise-inequality-bounds-tutorial.nblink create mode 100644 examples/piecewise-inequality-bounds.ipynb diff --git a/doc/index.rst b/doc/index.rst index fd7f9ed8..ed85bf6e 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -115,6 +115,7 @@ This package is published under MIT license. sos-constraints piecewise-linear-constraints piecewise-linear-constraints-tutorial + piecewise-inequality-bounds-tutorial manipulating-models testing-framework transport-tutorial diff --git a/doc/piecewise-inequality-bounds-tutorial.nblink b/doc/piecewise-inequality-bounds-tutorial.nblink new file mode 100644 index 00000000..698826c7 --- /dev/null +++ b/doc/piecewise-inequality-bounds-tutorial.nblink @@ -0,0 +1,3 @@ +{ + "path": "../examples/piecewise-inequality-bounds.ipynb" +} diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 6decb39c..8114a6a4 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -15,6 +15,8 @@ production functions within a linear programming framework. Quick Start ----------- +**Equality — lock variables onto the piecewise curve:** + .. code-block:: python import linopy @@ -23,16 +25,28 @@ Quick Start 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 + # fuel = f(power) on the piecewise curve defined by these breakpoints m.add_piecewise_formulation( (power, [0, 30, 60, 100]), (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. +**Inequality — bound one expression by the curve:** + +.. code-block:: python + + # fuel <= f(power). "auto" picks the cheapest correct formulation + # (pure LP with chord constraints when the curve's curvature matches + # the requested sign; SOS2/incremental otherwise). + m.add_piecewise_formulation( + (fuel, [0, 20, 30, 35]), # bounded output listed FIRST + (power, [0, 10, 20, 30]), # input always on the curve + sign="<=", + ) + +Each ``(expression, breakpoints)`` tuple pairs a variable with its breakpoint +values. All tuples share interpolation weights, so at any feasible point every +variable corresponds to the *same* point on the piecewise curve. API @@ -47,31 +61,15 @@ API (expr1, breakpoints1), (expr2, breakpoints2), ..., - method="auto", # "auto", "sos2", or "incremental" + sign="==", # "==", "<=", or ">=" + method="auto", # "auto", "sos2", "incremental", or "lp" active=None, # binary variable to gate the constraint name=None, # base name for generated variables/constraints ) -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 - - t = linopy.tangent_lines(x, x_points, y_points) - -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) # upper bound - m.add_constraints(fuel >= t) # lower bound +Creates auxiliary variables and constraints that enforce either an equality +(``sign="=="``, default) or a one-sided bound (``sign="<="`` / ``">="``) of the +first expression by the piecewise function of the rest. ``breakpoints`` and ``segments`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -84,47 +82,66 @@ Factory functions that create DataArrays with the correct dimension names: 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 ----------------- - -linopy provides two distinct tools for piecewise linear modelling. - -.. list-table:: - :header-rows: 1 - :widths: 30 35 35 - - * - - - ``add_piecewise_formulation`` - - ``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) + linopy.segments({"gen1": [(0, 10)], "gen2": [(0, 80)]}, dim="gen") -.. warning:: - ``tangent_lines`` does **not** work with equality. Writing - ``fuel == tangent_lines(...)`` creates one equality per segment, - which is overconstrained (infeasible except at breakpoints). - Use ``add_piecewise_formulation`` for equality. +The ``sign`` parameter — equality vs inequality +------------------------------------------------ -**When is the tangent-line bound tight?** +The ``sign`` argument of ``add_piecewise_formulation`` chooses whether all +expressions are locked onto the curve or whether the first one is bounded: -- :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) +- ``sign="=="`` (default): every expression lies *exactly* on the piecewise + curve — joint equality. All tuples are symmetric. +- ``sign="<="``: the **first** tuple's expression is bounded **above** by its + interpolated value; the remaining tuples are forced to equality (inputs on + the curve). Reads as *"first expression ≤ f(the rest)"*. +- ``sign=">="``: same but the first is bounded **below**. -For other combinations the bound is valid but loose (a relaxation). +This is the *first-tuple convention* — a single inequality direction applies to +one designated output; the other tuples parameterise the curve. + +**When is a one-sided bound wanted?** + +The primary reason to reach for ``sign="<="`` / ``">="`` is to unlock the +**LP chord formulation** — no SOS2, no binaries, just pure LP. On a +convex/concave curve with a matching sign, the chord inequalities are as +tight as SOS2, so you get the same optimum with a cheaper model. + +Beyond that: fuel-on-efficiency-envelope modelling (extra burn above the +curve is admissible, cost is still bounded), emissions caps where the curve +is itself a convex overestimator, or any situation where the curve bounds a +variable that need not sit *on* it. + +If the curvature doesn't match the sign (convex + ``"<="``, or concave + +``">="``), LP is not applicable — ``method="auto"`` falls back to +SOS2/incremental with the signed output link, which gives a valid but +much more expensive model. In that case prefer ``sign="=="`` unless you +genuinely need the one-sided semantics; the equality formulation is +typically simpler to reason about and no more expensive than the SOS2 +inequality variant. + +**Math (2-variable ``sign="<="``, concave :math:`f`).** The feasible region is +the **hypograph** of :math:`f` restricted to the breakpoint range: + +.. math:: + + \{ (x, y) \ :\ x_0 \le x \le x_n,\ y \le f(x) \}. + +For convex :math:`f` with ``sign=">="``, the feasible region is the epigraph. +Mismatched sign+curvature (convex + ``<=``, or concave + ``>=``) describes a +*non-convex* region — ``method="auto"`` will fall back to SOS2/incremental and +``method="lp"`` will raise. See the +:doc:`piecewise-inequality-bounds-tutorial` notebook for a full walkthrough. + +.. warning:: + + With ``sign="<="`` and ``active=0``, the output is only bounded **above** by + ``0`` — the lower side still comes from the output variable's own lower + bound. In the common case of non-negative outputs (fuel, cost, heat), set + ``lower=0`` on that variable: combined with the ``y ≤ 0`` constraint from + deactivation, this forces ``y = 0`` automatically. See the docstring for + the full recipe. Breakpoint Construction @@ -133,7 +150,7 @@ Breakpoint Construction From lists ~~~~~~~~~~ -The simplest form --- pass Python lists directly in the tuple: +The simplest form — pass Python lists directly in the tuple: .. code-block:: python @@ -195,14 +212,13 @@ Different generators can have different curves. Pass a dict to ), ) -Ragged lengths are NaN-padded automatically. Breakpoints are -auto-broadcast over remaining dimensions (e.g. ``time``). +Ragged lengths are NaN-padded automatically. Breakpoints are auto-broadcast +over remaining dimensions (e.g. ``time``). Disjunctive segments -~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~ -For disconnected operating regions (e.g. forbidden zones), use -``segments()``: +For disconnected operating regions (e.g. forbidden zones), use ``segments()``: .. code-block:: python @@ -211,14 +227,14 @@ For disconnected operating regions (e.g. forbidden zones), use (cost, linopy.segments([(0, 0), (125, 200)])), ) -The disjunctive formulation is selected automatically when breakpoints -have a segment dimension. +The disjunctive formulation is selected automatically when breakpoints have a +segment dimension. ``sign="<="`` / ``">="`` also works here; the signed link +is applied to the first tuple as usual. N-variable linking ~~~~~~~~~~~~~~~~~~ -Link any number of variables through shared breakpoints. All variables -are symmetric --- there is no distinguished "x" or "y": +Link any number of variables through shared breakpoints: .. code-block:: python @@ -228,16 +244,26 @@ are symmetric --- there is no distinguished "x" or "y": (heat, [0, 25, 55, 95]), ) +With ``sign="=="`` (default) all variables are symmetric. With a non-equality +sign the first tuple is the bounded output and the rest are forced to +equality. + Formulation Methods ------------------- -Pass ``method="auto"`` (the default) and linopy picks the best -formulation automatically: +Pass ``method="auto"`` (the default) and linopy picks the cheapest correct +formulation based on ``sign``, curvature and breakpoint layout: + +- **2-variable inequality on a convex/concave curve** → ``lp`` (chord lines, + no auxiliary variables) +- **All breakpoints monotonic** → ``incremental`` +- **Otherwise** → ``sos2`` +- **Disjunctive (segments)** → always ``sos2`` with binary segment selection -- **All breakpoints monotonic** --- incremental -- **Otherwise** --- SOS2 -- **Disjunctive** (segments) --- always SOS2 with binary selection +The resolved choice is exposed on the returned ``PiecewiseFormulation`` via +``.method`` (and ``.convexity`` when well-defined). An ``INFO``-level log line +explains the resolution whenever ``method="auto"`` is in play. SOS2 (Convex Combination) ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -256,6 +282,10 @@ Works for any breakpoint ordering. Introduces interpolation weights The SOS2 constraint ensures at most two adjacent :math:`\lambda_i` are non-zero, so every expression is interpolated within the same segment. +With ``sign != "=="`` the input tuples still use the equality above; the +**first** tuple's link is replaced by a one-sided ``e_1 \ \text{sign}\ \sum_i +\lambda_i B_{1,i}`` constraint. + .. note:: SOS2 is handled via branch-and-bound, similar to integer variables. @@ -269,19 +299,68 @@ Incremental (Delta) Formulation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ For **strictly monotonic** breakpoints. Uses fill-fraction variables -:math:`\delta_i` with binary indicators --- no SOS2 needed: +:math:`\delta_i` with binary indicators :math:`z_i`: .. math:: - &\delta_i \in [0, 1], \quad \delta_{i+1} \le \delta_i + &\delta_i \in [0, 1], \quad z_i \in \{0, 1\} + + &\delta_{i+1} \le \delta_i, \quad z_{i+1} \le \delta_i, \quad \delta_i \le z_i &e_j = B_{j,0} + \sum_{i=1}^{n} \delta_i \, (B_{j,i} - B_{j,i-1}) +With ``sign != "=="`` the same sign split as SOS2 applies: inputs use the +equality above; the first tuple's link uses the requested sign. + .. code-block:: python m.add_piecewise_formulation((power, xp), (fuel, yp), method="incremental") -**Limitation:** All breakpoint sequences must be strictly monotonic. +**Limitation:** breakpoint sequences must be strictly monotonic. + +LP (chord-line) Formulation +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For **2-variable inequality** on a **convex** or **concave** curve. Adds one +chord inequality per segment plus a domain bound — no auxiliary variables and +no MIP relaxation: + +.. math:: + + &y \ \text{sign}\ m_k \cdot x + c_k + \quad \forall\ \text{segments } k + + &x_0 \le x \le x_n + +where :math:`m_k = (y_{k+1} - y_k)/(x_{k+1} - x_k)` and +:math:`c_k = y_k - m_k\, x_k`. For concave :math:`f` with ``sign="<="``, +the intersection of all chord inequalities equals the hypograph of +:math:`f` on its domain. + +The LP dispatch requires curvature and sign to match: ``sign="<="`` needs +concave (or linear); ``sign=">="`` needs convex (or linear). A mismatch +is *not* just a loose bound — it describes the wrong region (see the +:doc:`piecewise-inequality-bounds-tutorial`). ``method="auto"`` detects +this and falls back; ``method="lp"`` raises. + +.. code-block:: python + + # y <= f(x) on a concave f — auto picks LP + m.add_piecewise_formulation((y, yp), (x, xp), sign="<=") + + # Or explicitly: + m.add_piecewise_formulation((y, yp), (x, xp), sign="<=", method="lp") + +**Not supported with** ``method="lp"``: ``sign="=="``, more than 2 tuples, +and ``active``. ``method="auto"`` falls back to SOS2/incremental in all +three cases. + +The underlying chord expressions are also exposed as a standalone helper, +``linopy.tangent_lines(x, x_pts, y_pts)``, which returns the per-segment +chord as a :class:`~linopy.expressions.LinearExpression` with no variables +created. Use it directly if you want to compose the chord bound with other +constraints by hand, without the domain bound that ``method="lp"`` adds +automatically. Disjunctive (Disaggregated Convex Combination) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -298,24 +377,6 @@ indicators :math:`z_k` select exactly one segment; SOS2 applies within it: No big-M constants are needed, giving a tight LP relaxation. -Tangent lines -~~~~~~~~~~~~~ - -For inequality bounds. Computes one tangent line per segment: - -.. math:: - - \text{tangent}_k(x) = m_k \cdot x + c_k - -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 - - t = linopy.tangent_lines(power, x_pts, y_pts) - m.add_constraints(fuel <= t) - Advanced Features ----------------- @@ -323,9 +384,9 @@ Advanced Features Active parameter (unit commitment) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -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: +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 @@ -339,11 +400,21 @@ linked expressions) are forced to zero: - ``commit=1``: power operates in [30, 100], fuel = f(power) - ``commit=0``: power = 0, fuel = 0 +Not supported with ``method="lp"``. + +.. note:: + + With a non-equality ``sign``, deactivation only pushes the signed bound to + ``0`` — the complementary side comes from the output variable's own + lower/upper bound. Set ``lower=0`` on naturally non-negative outputs + (fuel, cost, heat) to pin the output to zero on deactivation. See the + ``sign`` section above for details. + Auto-broadcasting ~~~~~~~~~~~~~~~~~ -Breakpoints are automatically broadcast to match expression dimensions. -You don't need ``expand_dims``: +Breakpoints are automatically broadcast to match expression dimensions — you +don't need ``expand_dims``: .. code-block:: python @@ -357,39 +428,63 @@ You don't need ``expand_dims``: NaN masking ~~~~~~~~~~~ -Trailing NaN values in breakpoints mask the corresponding lambda/delta -variables. This is useful for per-entity breakpoints with ragged -lengths: +Trailing NaN values in breakpoints mask the corresponding lambda / delta +variables (and, for LP, the corresponding chord constraints). This is useful +for per-entity breakpoints with ragged lengths: .. code-block:: python # gen1 has 3 breakpoints, gen2 has 2 (NaN-padded) bp = linopy.breakpoints({"gen1": [0, 50, 100], "gen2": [0, 80]}, dim="gen") -Interior NaN values (gaps in the middle) are not supported and raise -an error. +Interior NaN values (gaps in the middle) are not supported and raise an error. Generated variables and constraints ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Given base name ``name``: +Given a base name ``N`` (either user-supplied or auto-assigned like ``pwl0``), +each formulation creates a predictable set of names: + +**SOS2** (``method="sos2"``): + +- ``{N}_lambda`` — variable, interpolation weights +- ``{N}_convex`` — constraint, ``sum(lambda) == 1`` (or ``== active``) +- ``{N}_link`` — constraint, equality link (stacked inputs when + ``sign != "=="``, all tuples when ``sign="=="``) +- ``{N}_output_link`` — constraint, signed link on the first tuple + *(only when* ``sign != "=="`` *)* + +**Incremental** (``method="incremental"``): + +- ``{N}_delta`` — variable, fill fractions :math:`\delta_i` +- ``{N}_order_binary`` — variable, per-segment binaries :math:`z_i` +- ``{N}_delta_bound`` — constraint, :math:`\delta_i \le z_i` +- ``{N}_fill_order`` — constraint, :math:`\delta_{i+1} \le \delta_i` +- ``{N}_binary_order`` — constraint, :math:`z_{i+1} \le \delta_i` +- ``{N}_active_bound`` — constraint, :math:`\delta_i \le active` + *(only when* ``active`` *is given)* +- ``{N}_link`` / ``{N}_output_link`` — same split as SOS2 + +**LP** (``method="lp"``): -**SOS2:** -``{name}_lambda`` (variable), ``{name}_convex`` (constraint), -``{name}_x_link`` (constraint) +- ``{N}_chord`` — constraint, per-segment chord inequality +- ``{N}_domain_lo``, ``{N}_domain_hi`` — constraints, :math:`x_0 \le x \le x_n` +- *no auxiliary variables* -**Incremental:** -``{name}_delta`` (variable), ``{name}_inc_binary`` (variable), -``{name}_fill`` (constraint), ``{name}_x_link`` (constraint) +**Disjunctive** (``segments(...)`` input): -**Disjunctive:** -``{name}_binary`` (variable), ``{name}_select`` (constraint), -``{name}_lambda`` (variable), ``{name}_convex`` (constraint), -``{name}_x_link`` (constraint) +- ``{N}_segment_binary`` — variable, per-segment selectors :math:`z_k` +- ``{N}_select`` — constraint, ``sum(z_k) == 1`` (or ``== active``) +- ``{N}_lambda`` — variable, within-segment weights +- ``{N}_convex`` — constraint, per-segment :math:`\sum_i \lambda_{k,i} = z_k` +- ``{N}_link`` / ``{N}_output_link`` — same split as SOS2 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 of the + equality API (notebook) +- :doc:`piecewise-inequality-bounds-tutorial` — the ``sign`` parameter, the LP + formulation and the first-tuple convention (notebook) +- :doc:`sos-constraints` — low-level SOS1/SOS2 constraint API diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 79a3569e..3df9e25e 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -11,8 +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_formulation()`` for piecewise linear equality constraints with SOS2, incremental, and disjunctive formulations: ``m.add_piecewise_formulation((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 ``add_piecewise_formulation()`` for piecewise linear equality constraints with SOS2, incremental, and disjunctive formulations: ``m.add_piecewise_formulation((power, x_pts), (fuel, y_pts))``. Supports N-variable linking (e.g. CHP with fuel/power/heat) and per-entity breakpoints. ``method="auto"`` picks the cheapest correct formulation automatically. +* Add one-sided piecewise bounds via the ``sign`` parameter on ``add_piecewise_formulation``: ``sign="<="`` / ``">="`` applies the bound to the first tuple (first-tuple convention). On convex/concave curves with a matching sign, ``method="auto"`` dispatches to a pure-LP chord formulation (``method="lp"``) with no auxiliary variables and automatic domain bounds on the input. Mismatched curvature+sign is detected and falls back to SOS2/incremental with an explanatory info log. +* Add unit-commitment gating via the ``active`` parameter on ``add_piecewise_formulation``: a binary variable that, when zero, forces all auxiliary variables (and thus the linked expressions) to zero. Works with the SOS2, incremental, and disjunctive methods. +* Surface formulation metadata on the returned ``PiecewiseFormulation``: ``.method`` (resolved method name) and ``.convexity`` (``"convex"`` / ``"concave"`` / ``"linear"`` / ``"mixed"`` when well-defined). Both persist across netCDF round-trip. +* Add ``tangent_lines()`` as a low-level helper that returns per-segment chord expressions as a ``LinearExpression`` — no variables created. Most users should prefer ``add_piecewise_formulation(..., sign="<=")``, which builds on this helper and adds domain bounds and curvature validation. * 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 ``slopes_to_points()`` utility for converting segment slopes to breakpoint y-coordinates. diff --git a/examples/piecewise-inequality-bounds.ipynb b/examples/piecewise-inequality-bounds.ipynb new file mode 100644 index 00000000..e6684b62 --- /dev/null +++ b/examples/piecewise-inequality-bounds.ipynb @@ -0,0 +1,521 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Piecewise inequalities — the `sign` parameter\n", + "\n", + "`add_piecewise_formulation` accepts a ``sign`` parameter to express one-sided\n", + "bounds of the form `y ≤ f(x)` or `y ≥ f(x)`:\n", + "\n", + "```python\n", + "m.add_piecewise_formulation(\n", + " (fuel, y_pts), # output — gets the sign\n", + " (power, x_pts), # input — always equality\n", + " sign=\"<=\",\n", + ")\n", + "```\n", + "\n", + "This notebook walks through the math, the **first-tuple convention**, and\n", + "the feasible regions produced by each method (LP, SOS2, incremental).\n", + "\n", + "## Key points\n", + "\n", + "| | Behaviour |\n", + "|---|---|\n", + "| `sign=\"==\"` (default) | All expressions lie exactly on the curve. |\n", + "| `sign=\"<=\"` | First expression is bounded above by `f(rest)`. |\n", + "| `sign=\">=\"` | First expression is bounded below by `f(rest)`. |\n", + "\n", + "**First-tuple convention**: only the *first* tuple's variable gets the sign.\n", + "All remaining tuples are equality (inputs on the curve). This restriction\n", + "keeps the semantics unambiguous — it's always \"output sign function(inputs)\"." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-22T19:56:59.320352Z", + "start_time": "2026-04-22T19:56:58.210364Z" + } + }, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "import linopy" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Mathematical formulation\n", + "\n", + "### Equality (`sign=\"==\"`)\n", + "\n", + "For $N$ expressions $e_1, \\dots, e_N$ with breakpoints $B_{j,0}, \\dots, B_{j,n}$ per expression $j$, the SOS2 formulation introduces interpolation weights $\\lambda_i \\in [0,1]$:\n", + "\n", + "$$\n", + "\\sum_{i=0}^{n} \\lambda_i = 1, \\qquad \\text{SOS2}(\\lambda_0, \\dots, \\lambda_n),\n", + "\\qquad e_j = \\sum_{i=0}^{n} \\lambda_i \\, B_{j,i} \\ \\ \\forall j.\n", + "$$\n", + "\n", + "Every expression is tied to the same $\\lambda$ — they share a single point on the curve.\n", + "\n", + "### Inequality (`sign=\"<=\"` or `\">=\"`, first-tuple convention)\n", + "\n", + "The *first* expression $e_1$ is the output; the rest are inputs forced to equality:\n", + "\n", + "$$\n", + "\\sum_{i=0}^{n} \\lambda_i = 1, \\qquad \\text{SOS2}(\\lambda),\n", + "\\qquad e_j = \\sum_{i=0}^{n} \\lambda_i \\, B_{j,i}\\ \\ \\forall j \\ge 2,\n", + "\\qquad e_1 \\ \\text{sign}\\ \\sum_{i=0}^{n} \\lambda_i \\, B_{1,i}.\n", + "$$\n", + "\n", + "Inputs $e_2, \\dots, e_N$ are pinned to the curve at a shared $\\lambda$; the output $e_1$ is then bounded (above or below) by the interpolated value. The internal split is visible in the generated constraints: a single stacked `*_link` for inputs and a separate `*_output_link` carrying the sign.\n", + "\n", + "### LP method (2-variable inequality, convex/concave curve)\n", + "\n", + "For $y \\le f(x)$ on a concave $f$ (or $y \\ge f(x)$ on convex), we add one tangent (chord) per segment $k$:\n", + "\n", + "$$\n", + "y \\le m_k \\cdot x + c_k \\ \\ \\forall k,\n", + "\\qquad x_0 \\le x \\le x_n,\n", + "$$\n", + "\n", + "where $m_k = (y_{k+1}-y_k)/(x_{k+1}-x_k)$ and $c_k = y_k - m_k x_k$. The intersection of all chord inequalities equals the hypograph within the x-domain. No auxiliary variables are created.\n", + "\n", + "### Incremental (delta) formulation\n", + "\n", + "An MIP alternative to SOS2 for strictly monotonic breakpoints, using fill fractions $\\delta_i \\in [0,1]$ and binaries $z_i$ per segment:\n", + "\n", + "$$\n", + "\\delta_{i+1} \\le \\delta_i, \\quad z_{i+1} \\le \\delta_i, \\quad \\delta_i \\le z_i,\n", + "\\qquad e_j = B_{j,0} + \\sum_i \\delta_i\\,(B_{j,i+1}-B_{j,i}).\n", + "$$\n", + "\n", + "Same sign split as SOS2: inputs use equality, output uses the requested sign." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup — a concave curve\n", + "\n", + "We use a concave, monotonically increasing curve. With `sign=\"<=\"`, the LP\n", + "method is applicable (concave + `<=` is a tight relaxation)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-22T19:56:59.427867Z", + "start_time": "2026-04-22T19:56:59.325080Z" + } + }, + "outputs": [], + "source": [ + "x_pts = np.array([0.0, 10.0, 20.0, 30.0])\n", + "y_pts = np.array([0.0, 20.0, 30.0, 35.0]) # slopes 2, 1, 0.5 (concave)\n", + "\n", + "fig, ax = plt.subplots(figsize=(5, 4))\n", + "ax.plot(x_pts, y_pts, \"o-\", color=\"C0\", lw=2)\n", + "ax.set(xlabel=\"power\", ylabel=\"fuel\", title=\"Concave reference curve f(x)\")\n", + "ax.grid(alpha=0.3)\n", + "plt.tight_layout()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Three methods, identical feasible region\n", + "\n", + "With `sign=\"<=\"` and our concave curve, the three methods give the **same**\n", + "feasible region within `[x_0, x_n]`:\n", + "\n", + "- **`method=\"lp\"`** — tangent lines + domain bounds. No auxiliary variables.\n", + "- **`method=\"sos2\"`** — lambdas + SOS2 + split link (input equality, output\n", + " signed). Solver picks the segment.\n", + "- **`method=\"incremental\"`** — delta fractions + binaries + split link.\n", + " Same mathematics, MIP encoding instead of SOS2.\n", + "\n", + "`method=\"auto\"` dispatches to `\"lp\"` whenever applicable — it's always\n", + "preferable because it's pure LP.\n", + "\n", + "Let's verify they produce the same solution at `power=15`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-22T19:56:59.813355Z", + "start_time": "2026-04-22T19:56:59.434516Z" + } + }, + "outputs": [], + "source": [ + "def solve(method, power_val):\n", + " m = linopy.Model()\n", + " power = m.add_variables(lower=0, upper=30, name=\"power\")\n", + " fuel = m.add_variables(lower=0, upper=40, name=\"fuel\")\n", + " m.add_piecewise_formulation(\n", + " (fuel, y_pts), # output, signed\n", + " (power, x_pts), # input, ==\n", + " sign=\"<=\",\n", + " method=method,\n", + " )\n", + " m.add_constraints(power == power_val)\n", + " m.add_objective(-fuel) # maximise fuel to push against the bound\n", + " m.solve()\n", + " return float(m.solution[\"fuel\"]), list(m.variables), list(m.constraints)\n", + "\n", + "\n", + "for method in [\"lp\", \"sos2\", \"incremental\"]:\n", + " fuel_val, vars_, cons_ = solve(method, 15)\n", + " print(f\"{method:12}: fuel={fuel_val:.2f} vars={vars_} cons={cons_}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "All three give `fuel=25` at `power=15` (which is `f(15)` exactly) — the math\n", + "is equivalent. The LP method is strictly cheaper: no auxiliary variables,\n", + "just three chord constraints and two domain bounds.\n", + "\n", + "The SOS2 and incremental methods create lambdas (or deltas + binaries) and\n", + "split the link into an input-equality constraint plus a signed output link —\n", + "but the feasible region is the same." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Visualising the feasible region\n", + "\n", + "The feasible region for `(power, fuel)` with `sign=\"<=\"` is the **hypograph**\n", + "of `f` restricted to the curve's x-domain:\n", + "\n", + "$$\\{ (x, y) : x_0 \\le x \\le x_n,\\ y \\le f(x) \\}$$\n", + "\n", + "We colour green feasible points, red infeasible ones. Three test points:\n", + "\n", + "- `(15, 15)` — inside the curve, `15 ≤ f(15)=25` ✓\n", + "- `(15, 25)` — on the curve ✓\n", + "- `(15, 29)` — above `f(15)`, should be infeasible ✗\n", + "- `(35, 20)` — power beyond domain, infeasible ✗" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-22T19:57:00.004147Z", + "start_time": "2026-04-22T19:56:59.819631Z" + } + }, + "outputs": [], + "source": [ + "def in_hypograph(px, py):\n", + " if px < x_pts[0] or px > x_pts[-1]:\n", + " return False\n", + " return py <= np.interp(px, x_pts, y_pts)\n", + "\n", + "\n", + "xx, yy = np.meshgrid(np.linspace(-2, 38, 200), np.linspace(-5, 45, 200))\n", + "region = np.vectorize(in_hypograph)(xx, yy)\n", + "\n", + "test_points = [(15, 15), (15, 25), (15, 29), (35, 20)]\n", + "\n", + "fig, ax = plt.subplots(figsize=(6, 5))\n", + "ax.contourf(xx, yy, region, levels=[0.5, 1], colors=[\"lightsteelblue\"], alpha=0.5)\n", + "ax.plot(x_pts, y_pts, \"o-\", color=\"C0\", lw=2, label=\"f(x)\")\n", + "for px, py in test_points:\n", + " feas = in_hypograph(px, py)\n", + " ax.scatter(\n", + " [px], [py], color=\"green\" if feas else \"red\", zorder=5, s=80, edgecolors=\"black\"\n", + " )\n", + " ax.annotate(f\"({px}, {py})\", (px, py), textcoords=\"offset points\", xytext=(8, 5))\n", + "ax.set(\n", + " xlabel=\"power\",\n", + " ylabel=\"fuel\",\n", + " title=\"sign='<=' feasible region — hypograph of f(x) on [x_0, x_n]\",\n", + ")\n", + "ax.grid(alpha=0.3)\n", + "ax.legend()\n", + "plt.tight_layout()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "from mpl_toolkits.mplot3d.art3d import Poly3DCollection\n", + "\n", + "x_pts_3d = np.array([0.0, 30.0, 60.0, 100.0]) # power\n", + "z_pts_3d = np.array([0.0, 25.0, 55.0, 95.0]) # heat\n", + "y_pts_3d = np.array([0.0, 40.0, 85.0, 160.0]) # fuel (output)\n", + "\n", + "# Dense parameterisation of the 1-D curve\n", + "t_grid = np.linspace(0, len(x_pts_3d) - 1, 80)\n", + "power_c = np.interp(t_grid, np.arange(len(x_pts_3d)), x_pts_3d)\n", + "heat_c = np.interp(t_grid, np.arange(len(z_pts_3d)), z_pts_3d)\n", + "fuel_c = np.interp(t_grid, np.arange(len(y_pts_3d)), y_pts_3d)\n", + "\n", + "fig = plt.figure(figsize=(7, 6))\n", + "ax = fig.add_subplot(111, projection=\"3d\")\n", + "\n", + "# Shaded ribbon: (power(t), heat(t), fuel) for fuel from 0 to f(t)\n", + "polys = []\n", + "for i in range(len(t_grid) - 1):\n", + " quad = [\n", + " (power_c[i], heat_c[i], 0.0),\n", + " (power_c[i + 1], heat_c[i + 1], 0.0),\n", + " (power_c[i + 1], heat_c[i + 1], fuel_c[i + 1]),\n", + " (power_c[i], heat_c[i], fuel_c[i]),\n", + " ]\n", + " polys.append(quad)\n", + "coll = Poly3DCollection(polys, facecolor=\"lightsteelblue\", edgecolor=\"none\", alpha=0.35)\n", + "ax.add_collection3d(coll)\n", + "\n", + "# Upper boundary: the curve itself\n", + "ax.plot(power_c, heat_c, fuel_c, color=\"C0\", lw=2.5, label=\"curve f(t)\")\n", + "ax.scatter(x_pts_3d, z_pts_3d, y_pts_3d, color=\"C0\", s=50)\n", + "\n", + "# Lower boundary at fuel = 0\n", + "ax.plot(power_c, heat_c, 0 * fuel_c, color=\"gray\", lw=1, linestyle=\":\", alpha=0.7,\n", + " label=\"projection in (power, heat)\")\n", + "\n", + "ax.set(xlabel=\"power\", ylabel=\"heat\", zlabel=\"fuel\",\n", + " title=\"sign='<=' feasible region for 3 variables\")\n", + "ax.view_init(elev=20, azim=-70)\n", + "ax.legend()\n", + "plt.tight_layout()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-22T19:57:00.150636Z", + "start_time": "2026-04-22T19:57:00.012662Z" + } + }, + "outputs": [], + "source": [ + "from mpl_toolkits.mplot3d.art3d import Poly3DCollection\n", + "\n", + "x_pts_3d = np.array([0.0, 30.0, 60.0, 100.0]) # power\n", + "z_pts_3d = np.array([0.0, 25.0, 55.0, 95.0]) # heat\n", + "y_pts_3d = np.array([0.0, 40.0, 85.0, 160.0]) # fuel (output)\n", + "\n", + "# Dense parameterisation of the 1-D curve\n", + "t_grid = np.linspace(0, len(x_pts_3d) - 1, 80)\n", + "power_c = np.interp(t_grid, np.arange(len(x_pts_3d)), x_pts_3d)\n", + "heat_c = np.interp(t_grid, np.arange(len(z_pts_3d)), z_pts_3d)\n", + "fuel_c = np.interp(t_grid, np.arange(len(y_pts_3d)), y_pts_3d)\n", + "\n", + "\n", + "def draw_ribbon(ax, elev, azim, title):\n", + " # Shaded ribbon: (power(t), heat(t), fuel) for fuel from 0 to f(t)\n", + " polys = []\n", + " for i in range(len(t_grid) - 1):\n", + " quad = [\n", + " (power_c[i], heat_c[i], 0.0),\n", + " (power_c[i + 1], heat_c[i + 1], 0.0),\n", + " (power_c[i + 1], heat_c[i + 1], fuel_c[i + 1]),\n", + " (power_c[i], heat_c[i], fuel_c[i]),\n", + " ]\n", + " polys.append(quad)\n", + " coll = Poly3DCollection(\n", + " polys, facecolor=\"lightsteelblue\", edgecolor=\"none\", alpha=0.35\n", + " )\n", + " ax.add_collection3d(coll)\n", + "\n", + " # Upper boundary: the curve itself\n", + " ax.plot(power_c, heat_c, fuel_c, color=\"C0\", lw=2.5)\n", + " ax.scatter(x_pts_3d, z_pts_3d, y_pts_3d, color=\"C0\", s=40)\n", + "\n", + " # (power, heat) projection at fuel=0\n", + " ax.plot(power_c, heat_c, 0 * fuel_c, color=\"gray\", lw=1, linestyle=\":\", alpha=0.7)\n", + "\n", + " ax.set(xlabel=\"power\", ylabel=\"heat\", zlabel=\"fuel\", title=title)\n", + " ax.view_init(elev=elev, azim=azim)\n", + "\n", + "\n", + "fig = plt.figure(figsize=(13, 5))\n", + "ax1 = fig.add_subplot(131, projection=\"3d\")\n", + "draw_ribbon(ax1, elev=20, azim=-70, title=\"perspective\")\n", + "ax2 = fig.add_subplot(132, projection=\"3d\")\n", + "draw_ribbon(ax2, elev=0, azim=-90, title=\"side (power vs fuel)\")\n", + "ax3 = fig.add_subplot(133, projection=\"3d\")\n", + "draw_ribbon(ax3, elev=90, azim=-90, title=\"top (power vs heat)\")\n", + "plt.tight_layout()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The ribbon has the shape *(input curve)* × *[0, output value]*. Points *above*\n", + "the curve in fuel are infeasible; points *below* are feasible, provided\n", + "`(power, heat)` lies on the curve's projection. If the user tried to set\n", + "`power=50, heat=20` (off-curve), the formulation would be infeasible — the\n", + "inputs must be consistent with a shared $\\lambda$." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## The first-tuple convention\n", + "\n", + "Why does only *one* variable get the sign? Because the math of\n", + "\"bound one output by a function of the others\" has a single inequality\n", + "direction. For 3+ variables:\n", + "\n", + "```python\n", + "m.add_piecewise_formulation(\n", + " (fuel, y_pts), # output — sign\n", + " (power, x_pts), # input — ==\n", + " (heat, z_pts), # input — ==\n", + " sign=\"<=\",\n", + ")\n", + "```\n", + "\n", + "reads as `fuel ≤ g(power, heat)` on the joint curve. All inputs must lie on\n", + "the curve (equality); only the output is bounded.\n", + "\n", + "Allowing arbitrary per-variable signs would open up cases like\n", + "\"`fuel ≤ f(power)` AND `heat ≤ f(power)`\" which is a dominated region, not a\n", + "hypograph — mathematically valid but rarely what users want. Restricting to\n", + "one output keeps the semantics unambiguous." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## When is LP the right choice?\n", + "\n", + "`tangent_lines` imposes the **intersection** of chord inequalities. Whether\n", + "that intersection matches the true hypograph/epigraph of `f` depends on the\n", + "curvature × sign combination:\n", + "\n", + "| curvature | `sign=\"<=\"` | `sign=\">=\"` |\n", + "|-----------|-------------|-------------|\n", + "| **concave** | **hypograph (exact ✓)** | **wrong region** — requires `y ≥ max_k chord_k(x) > f(x)` |\n", + "| **convex** | **wrong region** — requires `y ≤ min_k chord_k(x) < f(x)` | **epigraph (exact ✓)** |\n", + "| linear | exact | exact |\n", + "| mixed (non-convex) | convex hull of `f` (wrong for exact hypograph) | concave hull of `f` (wrong for exact epigraph) |\n", + "\n", + "In the ✗ cases, tangent lines do **not** give a loose relaxation — they give\n", + "a **strictly wrong feasible region** that rejects points satisfying the true\n", + "constraint. Example: for a concave `f` with `y ≥ f(x)`, the chord of any\n", + "segment extrapolated over another segment's x-range lies *above* `f`, so the\n", + "constraint `y ≥ max_k chord_k(x)` forbids `y = f(x)` itself.\n", + "\n", + "`method=\"auto\"` dispatches to LP only in the two **exact** cases (concave+`<=`\n", + "or convex+`>=`). For the other combinations it falls back to SOS2 or\n", + "incremental, which encode the hypograph/epigraph exactly via discrete segment\n", + "selection.\n", + "\n", + "`method=\"lp\"` explicitly forces LP and raises on a mismatched curvature\n", + "rather than silently producing a wrong feasible region.\n", + "\n", + "For **non-convex** curves with either sign, the only exact option is a\n", + "piecewise formulation. That's what `sign=\"<=\"` does internally: it falls\n", + "back to SOS2/incremental with the sign on the output link. No relaxation,\n", + "no wrong bounds.\n", + "\n", + "For **3+ variables** with inequality, LP is never applicable: tangent lines\n", + "express *one input → one output*. With multiple inputs on a 1-D curve in\n", + "N-D space, identifying which segment we're on requires SOS2/binary. Auto\n", + "dispatches to SOS2 here." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-22T19:57:00.225061Z", + "start_time": "2026-04-22T19:57:00.167623Z" + } + }, + "outputs": [], + "source": [ + "# 1. Non-convex curve: auto falls back (LP relaxation would be loose)\n", + "x_nc = [0, 10, 20, 30]\n", + "y_nc = [0, 20, 10, 30] # slopes change sign → mixed convexity\n", + "\n", + "m1 = linopy.Model()\n", + "x1 = m1.add_variables(lower=0, upper=30, name=\"x\")\n", + "y1 = m1.add_variables(lower=0, upper=40, name=\"y\")\n", + "f1 = m1.add_piecewise_formulation((y1, y_nc), (x1, x_nc), sign=\"<=\")\n", + "print(f\"non-convex + '<=' → {f1.method}\")\n", + "\n", + "# 2. Concave curve + sign='>=': LP would be loose → auto falls back to MIP\n", + "x_cc = [0, 10, 20, 30]\n", + "y_cc = [0, 20, 30, 35] # concave\n", + "\n", + "m2 = linopy.Model()\n", + "x2 = m2.add_variables(lower=0, upper=30, name=\"x\")\n", + "y2 = m2.add_variables(lower=0, upper=40, name=\"y\")\n", + "f2 = m2.add_piecewise_formulation((y2, y_cc), (x2, x_cc), sign=\">=\")\n", + "print(f\"concave + '>=' → {f2.method}\")\n", + "\n", + "# 3. Explicit method=\"lp\" with mismatched curvature raises\n", + "try:\n", + " m3 = linopy.Model()\n", + " x3 = m3.add_variables(lower=0, upper=30, name=\"x\")\n", + " y3 = m3.add_variables(lower=0, upper=40, name=\"y\")\n", + " m3.add_piecewise_formulation((y3, y_cc), (x3, x_cc), sign=\">=\", method=\"lp\")\n", + "except ValueError as e:\n", + " print(f\"lp(concave, '>=') → raises: {e}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "- Use `sign=\"=\"` (default) for exact equality on the curve.\n", + "- Use `sign=\"<=\"` / `sign=\">=\"` for one-sided bounds on the first tuple's\n", + " expression.\n", + "- `method=\"auto\"` picks the most efficient formulation: LP for convex/concave\n", + " 2-variable inequalities, otherwise SOS2 or incremental.\n", + "- Only the *first* tuple gets the sign — all other tuples are always\n", + " equality. This restriction keeps semantics unambiguous." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 71d10a11..2285c3f6 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -3,22 +3,28 @@ { "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_formulation((power, x_pts), (fuel, y_pts))\n```" + "source": [ + "# Piecewise Linear Constraints Tutorial\n", + "\n", + "`add_piecewise_formulation` links variables through shared breakpoint weights. Every section below stacks one feature on top of a small shared dispatch pattern — if you want the math, see the [reference page](piecewise-linear-constraints). For inequality bounds and the LP chord formulation in depth, see the [inequality bounds notebook](piecewise-inequality-bounds-tutorial).\n", + "\n", + "The baseline we extend:\n", + "\n", + "```python\n", + "m.add_piecewise_formulation(\n", + " (power, [0, 30, 60, 100]),\n", + " (fuel, [0, 36, 84, 170]),\n", + ")\n", + "```" + ] }, { "cell_type": "code", "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T17:50:20.809300Z", - "start_time": "2026-04-01T17:50:20.202138Z" - }, - "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" + "end_time": "2026-04-22T23:31:58.302751Z", + "start_time": "2026-04-22T23:31:58.299283Z" } }, "outputs": [], @@ -32,90 +38,23 @@ "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()" + "def plot_curve(bp_x, bp_y, operating_x, operating_y, *, color=\"C0\", ax=None):\n", + " \"\"\"PWL curve with solver's operating points overlaid.\"\"\"\n", + " ax = ax or plt.subplots(figsize=(4.5, 3.5))[1]\n", + " ax.plot(bp_x, bp_y, \"o-\", color=color, label=\"breakpoints\")\n", + " ax.plot(operating_x, operating_y, \"D\", color=color, ms=10, label=\"solved\")\n", + " ax.set(xlabel=\"power\", ylabel=\"fuel\")\n", + " ax.legend()\n", + " return ax" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## 1. SOS2 formulation — Gas turbine\n", + "## 1. Getting started\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." + "A gas turbine with a convex heat rate. Each `(variable, breakpoints)` tuple pairs a variable with its breakpoint values. All tuples share interpolation weights, so at any feasible point every variable corresponds to the *same* point on the curve." ] }, { @@ -123,59 +62,27 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T17:50:20.848260Z", - "start_time": "2026-04-01T17:50:20.813939Z" - }, - "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" + "end_time": "2026-04-22T23:31:58.464773Z", + "start_time": "2026-04-22T23:31:58.310016Z" } }, "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-01T17:50:20.884905Z", - "start_time": "2026-04-01T17:50:20.851433Z" - }, - "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", + "x_pts = [0, 30, 60, 100]\n", + "y_pts = [0, 36, 84, 170]\n", + "demand = xr.DataArray([50, 80, 30], coords=[time])\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", + "m = linopy.Model()\n", + "power = m.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", + "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", "\n", - "demand1 = xr.DataArray([50, 80, 30], coords=[time])\n", - "m1.add_constraints(power >= demand1, name=\"demand\")\n", - "m1.add_objective(fuel.sum())\n", + "pwf = m.add_piecewise_formulation((power, x_pts), (fuel, y_pts))\n", + "m.add_constraints(power == demand, name=\"demand\")\n", + "m.add_objective(fuel.sum())\n", + "m.solve(reformulate_sos=\"auto\")\n", "\n", - "# breakpoints are auto-broadcast to match the time dimension\n", - "m1.add_piecewise_formulation(\n", - " (power, x_pts1),\n", - " (fuel, y_pts1),\n", - " name=\"pwl\",\n", - " method=\"sos2\",\n", - ")" + "print(pwf) # inspect the auto-resolved method\n", + "m.solution[[\"power\", \"fuel\"]].to_pandas()" ] }, { @@ -183,168 +90,30 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T17:50:20.889691Z", - "start_time": "2026-04-01T17:50:20.888003Z" + "end_time": "2026-04-22T23:31:58.532078Z", + "start_time": "2026-04-22T23:31:58.473509Z" } }, "outputs": [], "source": [ - "print(m1)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:20.941957Z", - "start_time": "2026-04-01T17:50:20.900785Z" - }, - "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-01T17:50:20.957062Z", - "start_time": "2026-04-01T17:50:20.946704Z" - }, - "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-01T17:50:21.068805Z", - "start_time": "2026-04-01T17:50:20.970458Z" - }, - "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\")" + "plot_curve(x_pts, y_pts, m.solution[\"power\"].values, m.solution[\"fuel\"].values);" ] }, { "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-01T17:50:21.074995Z", - "start_time": "2026-04-01T17:50:21.072706Z" - }, - "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-01T17:50:21.135253Z", - "start_time": "2026-04-01T17:50:21.083396Z" - }, - "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", + "## 2. Picking a method\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", + "`method=\"auto\"` (default) picks the cheapest correct formulation based on sign, curvature and breakpoint layout. The explicit options — `\"sos2\"`, `\"incremental\"`, `\"lp\"` — give the same optimum on equality cases where they all apply, so the choice is about **cost** (auxiliary variables, solver capability), not correctness.\n", "\n", - "demand2 = xr.DataArray([80, 120, 50], coords=[time])\n", - "m2.add_constraints(power >= demand2, name=\"demand\")\n", - "m2.add_objective(fuel.sum())\n", + "| method | needs | creates |\n", + "|---|---|---|\n", + "| `sos2` | SOS2-capable solver | lambdas (continuous) |\n", + "| `incremental` | MIP solver, strictly monotonic breakpoints | deltas (continuous) + binaries |\n", + "| `lp` | any LP solver | no variables — requires `sign != \"==\"`, 2 tuples, matching curvature |\n", "\n", - "m2.add_piecewise_formulation(\n", - " (power, x_pts2),\n", - " (fuel, y_pts2),\n", - " name=\"pwl\",\n", - " method=\"incremental\",\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.185956Z", - "start_time": "2026-04-01T17:50:21.147474Z" - }, - "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\");" + "Below: all applicable methods yield the same fuel dispatch on this convex curve." ] }, { @@ -352,84 +121,33 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.205500Z", - "start_time": "2026-04-01T17:50:21.200814Z" - }, - "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" + "end_time": "2026-04-22T23:31:58.952185Z", + "start_time": "2026-04-22T23:31:58.537015Z" } }, "outputs": [], "source": [ - "m2.solution[[\"power\", \"fuel\"]].to_pandas()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.289611Z", - "start_time": "2026-04-01T17:50:21.213993Z" - }, - "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 = linopy.breakpoints({\"power\": x_pts2.values, \"fuel\": y_pts2.values}, dim=\"var\")\n", - "plot_pwl_results(m2, bp2, demand2, color=\"C1\")" + "def solve_method(method):\n", + " m = linopy.Model()\n", + " power = m.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", + " fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", + " m.add_piecewise_formulation((power, x_pts), (fuel, y_pts), method=method)\n", + " m.add_constraints(power == demand, name=\"demand\")\n", + " m.add_objective(fuel.sum())\n", + " m.solve(reformulate_sos=\"auto\")\n", + " return m.solution[\"fuel\"].to_pandas()\n", + "\n", + "\n", + "pd.DataFrame({m: solve_method(m) for m in [\"auto\", \"sos2\", \"incremental\"]})" ] }, { "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", + "## 3. Disjunctive segments — gaps in the operating range\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-01T17:50:21.296416Z", - "start_time": "2026-04-01T17:50:21.293422Z" - }, - "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())" + "When operating regions are **disconnected** (a diesel generator that is either off or running in [50, 80] MW, never in between), use `segments()` instead of `breakpoints()`. A binary picks which segment is active; inside it SOS2 interpolates as usual." ] }, { @@ -437,462 +155,43 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.351922Z", - "start_time": "2026-04-01T17:50:21.304030Z" - }, - "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" + "end_time": "2026-04-22T23:31:59.092539Z", + "start_time": "2026-04-22T23:31:58.956054Z" } }, "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", + "m = linopy.Model()\n", + "power = m.add_variables(name=\"power\", lower=0, upper=80, coords=[time])\n", + "cost = m.add_variables(name=\"cost\", lower=0, coords=[time])\n", + "backup = m.add_variables(name=\"backup\", lower=0, coords=[time])\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())\n", - "\n", - "m3.add_piecewise_formulation(\n", - " (power, x_seg),\n", - " (cost, y_seg),\n", - " name=\"pwl\",\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.398282Z", - "start_time": "2026-04-01T17:50:21.355402Z" - }, - "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\")" + "m.add_piecewise_formulation(\n", + " (power, linopy.segments([(0, 0), (50, 80)])), # two disjoint segments\n", + " (cost, linopy.segments([(0, 0), (125, 200)])),\n", + ")\n", + "m.add_constraints(power + backup == xr.DataArray([15, 60, 75], coords=[time]))\n", + "m.add_objective(cost.sum() + 10 * backup.sum())\n", + "m.solve(reformulate_sos=\"auto\")\n", + "m.solution[[\"power\", \"cost\", \"backup\"]].to_pandas()" ] }, { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.413359Z", - "start_time": "2026-04-01T17:50:21.408184Z" - }, - "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": [], + "cell_type": "markdown", + "metadata": {}, "source": [ - "m3.solution[[\"power\", \"cost\", \"backup\"]].to_pandas()" + "At *t=1* the 15 MW demand falls in the forbidden zone; the unit sits at 0 and backup fills the gap." ] }, { "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", - "*", - "*", + "## 4. Inequality bounds — `sign=\"<=\"`\n", "\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", + "If you don't need `fuel = f(power)` exactly but just `fuel ≤ f(power)`, pass `sign=\"<=\"`. On a **concave** curve with `<=` (or a **convex** curve with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation — no binaries, no SOS2. The first tuple is the bounded output; the rest are inputs forced onto the curve.\n", "\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", - "." + "See the [inequality bounds notebook](piecewise-inequality-bounds-tutorial) for mismatched curvature, multi-variable cases and what the auto-dispatch falls back to." ] }, { @@ -900,1062 +199,40 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.449956Z", - "start_time": "2026-04-01T17:50:21.433179Z" - }, - "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" + "end_time": "2026-04-22T23:31:59.210868Z", + "start_time": "2026-04-22T23:31:59.098774Z" } }, "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", - "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())\n", + "m = linopy.Model()\n", + "power = m.add_variables(name=\"power\", lower=0, upper=120, coords=[time])\n", + "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", "\n", - "# tangent_lines returns one LinearExpression per segment — pure LP, no aux variables\n", - "linopy.tangent_lines(power, x_pts4, y_pts4)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.470263Z", - "start_time": "2026-04-01T17:50:21.454181Z" - } - }, - "outputs": [], - "source": [ - "t = linopy.tangent_lines(power, x_pts4, y_pts4)\n", - "m4.add_constraints(fuel <= t, name=\"pwl\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.498563Z", - "start_time": "2026-04-01T17:50:21.476327Z" - }, - "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-01T17:50:21.512519Z", - "start_time": "2026-04-01T17:50:21.508408Z" - }, - "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-01T17:50:21.608738Z", - "start_time": "2026-04-01T17:50:21.525541Z" - }, - "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", + "# concave curve: diminishing marginal fuel per MW\n", + "pwf = m.add_piecewise_formulation(\n", + " (fuel, [0, 50, 90, 120]), # bounded output (listed FIRST)\n", + " (power, [0, 40, 80, 120]),\n", + " sign=\"<=\",\n", + ")\n", + "m.add_constraints(power == xr.DataArray([30, 80, 100], coords=[time]))\n", + "m.add_objective(-fuel.sum()) # push fuel against the bound\n", + "m.solve(reformulate_sos=\"auto\")\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-01T17:50:21.614899Z", - "start_time": "2026-04-01T17:50:21.612589Z" - }, - "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)" + "print(f\"resolved method={pwf.method}, curvature={pwf.convexity}\")\n", + "m.solution[[\"power\", \"fuel\"]].to_pandas()" ] }, { "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", + "## 5. Unit commitment — `active`\n", "\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", - "$", + "A binary variable gates the whole formulation. `active=0` forces the PWL variables (and thus all linked outputs) to zero. Combined with the natural `lower=0` on cost/fuel/heat, this gives a clean on/off coupling:\n", "\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", - "." + "- `active=1`: the unit operates in its full range, outputs tied to the curve.\n", + "- `active=0`: `power = 0`, `fuel = 0`." ] }, { @@ -1963,799 +240,39 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.626940Z", - "start_time": "2026-04-01T17:50:21.624504Z" + "end_time": "2026-04-22T23:31:59.422636Z", + "start_time": "2026-04-22T23:31:59.232150Z" } }, "outputs": [], "source": [ - "# Unit parameters: operates between 30-100 MW when on\n", + "m = linopy.Model()\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-01T17:50:21.707770Z", - "start_time": "2026-04-01T17:50:21.635963Z" - } - }, - "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", - "# 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", + "power = m.add_variables(name=\"power\", lower=0, upper=p_max, coords=[time])\n", + "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", + "backup = m.add_variables(name=\"backup\", lower=0, coords=[time])\n", + "commit = m.add_variables(name=\"commit\", binary=True, coords=[time])\n", "\n", - "# Objective: fuel + startup cost + backup at /MW\n", - "m6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())\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_formulation(\n", - " (power, x_pts6),\n", - " (fuel, y_pts6),\n", + "m.add_piecewise_formulation(\n", + " (power, [p_min, 60, p_max]),\n", + " (fuel, [40, 90, 170]),\n", " active=commit,\n", - " name=\"pwl\",\n", - " method=\"incremental\",\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.766060Z", - "start_time": "2026-04-01T17:50:21.710952Z" - } - }, - "outputs": [], - "source": [ - "m6.solve(reformulate_sos=\"auto\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.775881Z", - "start_time": "2026-04-01T17:50:21.770500Z" - } - }, - "outputs": [], - "source": [ - "m6.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.866232Z", - "start_time": "2026-04-01T17:50:21.784879Z" - } - }, - "outputs": [], - "source": [ - "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", - "*", - "*", - ",", - " ", - "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", - "`", - " ", - "—", - " ", - "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", - "." + ")\n", + "# demand below p_min at t=1 — commit must be 0 and backup covers it\n", + "m.add_constraints(power + backup == xr.DataArray([15, 80, 40], coords=[time]))\n", + "m.add_objective(fuel.sum() + 50 * commit.sum() + 200 * backup.sum())\n", + "m.solve(reformulate_sos=\"auto\")\n", + "m.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()" ] }, { "cell_type": "markdown", "metadata": {}, "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", + "## 6. N-variable linking — CHP plant\n", "\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", - "." + "More than two variables can share the same interpolation — useful for combined heat-and-power plants where power, fuel and heat are all functions of a single operating point." ] }, { @@ -2763,215 +280,64 @@ "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.872549Z", - "start_time": "2026-04-01T17:50:21.869653Z" + "end_time": "2026-04-22T23:31:59.598540Z", + "start_time": "2026-04-22T23:31:59.433551Z" } }, "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", + "m = linopy.Model()\n", + "power = m.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", + "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", + "heat = m.add_variables(name=\"heat\", lower=0, coords=[time])\n", + "\n", + "m.add_piecewise_formulation(\n", + " (power, [0, 30, 60, 100]),\n", + " (fuel, [0, 40, 85, 160]),\n", + " (heat, [0, 25, 55, 95]),\n", ")\n", - "print(\"CHP breakpoints:\")\n", - "print(bp_chp.to_pandas())" + "m.add_constraints(fuel == xr.DataArray([20, 100, 160], coords=[time]))\n", + "m.add_objective(power.sum())\n", + "m.solve(reformulate_sos=\"auto\")\n", + "m.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas().round(2)" ] }, { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.920666Z", - "start_time": "2026-04-01T17:50:21.879829Z" - } - }, - "outputs": [], + "cell_type": "markdown", + "metadata": {}, "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", - "# 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", - "m7.add_objective(fuel.sum())\n", + "## 7. Per-entity breakpoints — a fleet of generators\n", "\n", - "# N-variable: all three linked through shared interpolation weights\n", - "m7.add_piecewise_formulation(\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", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.964861Z", - "start_time": "2026-04-01T17:50:21.926856Z" - } - }, - "outputs": [], - "source": [ - "m7.solve(reformulate_sos=\"auto\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:21.981116Z", - "start_time": "2026-04-01T17:50:21.976461Z" - } - }, - "outputs": [], - "source": [ - "m7.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas().round(2)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:22.088132Z", - "start_time": "2026-04-01T17:50:22.003877Z" - } - }, - "outputs": [], - "source": [ - "plot_pwl_results(m7, bp_chp, power_dispatch, x_name=\"fuel\")" + "Pass a dict to `breakpoints()` with entity names as keys for different curves per entity. Ragged lengths are NaN-padded automatically, and breakpoints broadcast over any remaining dimensions (here, `time`)." ] }, - { - "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-01T17:50:22.097775Z", - "start_time": "2026-04-01T17:50:22.094150Z" + "end_time": "2026-04-22T23:31:59.801734Z", + "start_time": "2026-04-22T23:31:59.606692Z" } }, "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-01T17:50:22.177554Z", - "start_time": "2026-04-01T17:50:22.111112Z" - } - }, - "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", - "demand8 = xr.DataArray([80, 120, 60], coords=[time])\n", - "m8.add_constraints(power.sum(\"gen\") >= demand8, name=\"demand\")\n", - "m8.add_objective(fuel.sum())\n", - "\n", - "# Per-entity breakpoints: each generator gets its own curve\n", - "m8.add_piecewise_formulation(\n", - " (power, x_gen),\n", - " (fuel, y_gen),\n", - " name=\"pwl\",\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:22.234795Z", - "start_time": "2026-04-01T17:50:22.185178Z" - } - }, - "outputs": [], - "source": [ - "m8.solve(reformulate_sos=\"auto\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:22.245727Z", - "start_time": "2026-04-01T17:50:22.242646Z" - } - }, - "outputs": [], - "source": [ - "m8.constraints[\"demand\"]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-01T17:50:22.346404Z", - "start_time": "2026-04-01T17:50:22.260902Z" - } - }, - "outputs": [], - "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", - " 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", "\n", - "plt.tight_layout()" + "m = linopy.Model()\n", + "power = m.add_variables(name=\"power\", lower=0, upper=150, coords=[gens, time])\n", + "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[gens, time])\n", + "m.add_piecewise_formulation((power, x_gen), (fuel, y_gen))\n", + "m.add_constraints(power.sum(\"gen\") == xr.DataArray([80, 120, 50], coords=[time]))\n", + "m.add_objective(fuel.sum())\n", + "m.solve(reformulate_sos=\"auto\")\n", + "m.solution[[\"power\", \"fuel\"]].to_dataframe()" ] } ], @@ -2991,7 +357,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.3" + "version": "3.13.2" } }, "nbformat": 4, diff --git a/linopy/constants.py b/linopy/constants.py index 268a7ac1..b8aef6ef 100644 --- a/linopy/constants.py +++ b/linopy/constants.py @@ -49,6 +49,13 @@ PWL_DELTA_BOUND_SUFFIX = "_delta_bound" PWL_BINARY_ORDER_SUFFIX = "_binary_order" PWL_ACTIVE_BOUND_SUFFIX = "_active_bound" +PWL_OUTPUT_LINK_SUFFIX = "_output_link" +PWL_CHORD_SUFFIX = "_chord" +PWL_DOMAIN_LO_SUFFIX = "_domain_lo" +PWL_DOMAIN_HI_SUFFIX = "_domain_hi" + +PWL_METHODS: set[str] = {"sos2", "lp", "incremental", "auto"} +PWL_CONVEXITIES: set[str] = {"convex", "concave", "linear", "mixed"} BREAKPOINT_DIM = "_breakpoint" SEGMENT_DIM = "_segment" LP_SEG_DIM = f"{BREAKPOINT_DIM}_seg" diff --git a/linopy/io.py b/linopy/io.py index a753b828..ed32cf1d 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -1154,6 +1154,7 @@ def with_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: "method": pwl.method, "variables": pwl.variable_names, "constraints": pwl.constraint_names, + "convexity": pwl.convexity, } for name, pwl in m._piecewise_formulations.items() } @@ -1265,6 +1266,7 @@ def get_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: variable_names=d["variables"], constraint_names=d["constraints"], model=m, + convexity=d.get("convexity"), ) return m diff --git a/linopy/piecewise.py b/linopy/piecewise.py index a0e2a5ba..46483d31 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -7,7 +7,9 @@ from __future__ import annotations +import logging from collections.abc import Sequence +from dataclasses import dataclass from numbers import Real from typing import TYPE_CHECKING, Literal, TypeAlias @@ -18,20 +20,30 @@ from linopy.constants import ( BREAKPOINT_DIM, + EQUAL, + GREATER_EQUAL, HELPER_DIMS, + LESS_EQUAL, LP_SEG_DIM, PWL_ACTIVE_BOUND_SUFFIX, PWL_BINARY_ORDER_SUFFIX, + PWL_CHORD_SUFFIX, PWL_CONVEX_SUFFIX, PWL_DELTA_BOUND_SUFFIX, PWL_DELTA_SUFFIX, + PWL_DOMAIN_HI_SUFFIX, + PWL_DOMAIN_LO_SUFFIX, PWL_FILL_ORDER_SUFFIX, PWL_LAMBDA_SUFFIX, PWL_LINK_SUFFIX, + PWL_METHODS, PWL_ORDER_BINARY_SUFFIX, + PWL_OUTPUT_LINK_SUFFIX, PWL_SEGMENT_BINARY_SUFFIX, PWL_SELECT_SUFFIX, SEGMENT_DIM, + SIGNS, + sign_replace_dict, ) if TYPE_CHECKING: @@ -41,6 +53,8 @@ from linopy.types import LinExprLike from linopy.variables import Variables +logger = logging.getLogger(__name__) + # Accepted input types for breakpoint-like data BreaksLike: TypeAlias = ( Sequence[float] | DataArray | pd.Series | pd.DataFrame | dict[str, Sequence[float]] @@ -67,9 +81,30 @@ class PiecewiseFormulation: Groups all auxiliary variables and constraints created by a single piecewise formulation. Stores only names internally; ``variables`` and ``constraints`` properties return live views from the model. + + Attributes + ---------- + name : str + Formulation name (used as prefix for auxiliary variables and + constraints). + method : str + Resolved method — one of ``{"sos2", "incremental", "lp"}``. Never + ``"auto"``; if the caller passed ``method="auto"``, this holds the + method actually chosen. + convexity : {"convex", "concave", "linear", "mixed"} or None + Shape of the piecewise curve along the breakpoint axis when it is + well-defined (exactly two expressions, non-disjunctive, strictly + monotonic ``x`` breakpoints). ``None`` otherwise. """ - __slots__ = ("name", "method", "variable_names", "constraint_names", "_model") + __slots__ = ( + "name", + "method", + "convexity", + "variable_names", + "constraint_names", + "_model", + ) def __init__( self, @@ -78,9 +113,11 @@ def __init__( variable_names: list[str], constraint_names: list[str], model: Model, + convexity: Literal["convex", "concave", "linear", "mixed"] | None = None, ) -> None: self.name = name self.method = method + self.convexity = convexity self.variable_names = variable_names self.constraint_names = constraint_names self._model = model @@ -107,7 +144,10 @@ def __repr__(self) -> str: header = f"PiecewiseFormulation `{self.name}`" if dims_str: header += f" [{dims_str}]" - r = f"{header} — {self.method}\n" + suffix = self.method + if self.convexity is not None: + suffix += f", {self.convexity}" + r = f"{header} — {suffix}\n" r += " Variables:\n" for vname, var in self.variables.items(): dims = ", ".join(str(d) for d in var.coords) if var.coords else "" @@ -437,14 +477,19 @@ def tangent_lines( y_points: BreaksLike, ) -> LinearExpression: r""" - Compute tangent-line expressions for a piecewise linear function. + Compute tangent-line (chord) 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`. + Low-level helper returning a :class:`~linopy.expressions.LinearExpression` + with an extra segment dimension. Each element along the segment dimension + is the chord of one segment: :math:`m_k \cdot x + c_k`. No auxiliary + variables are created. - Use the result in a regular constraint to create an upper or lower - bound: + For most users: prefer :func:`add_piecewise_formulation` with + ``sign="<="`` / ``">="`` — it builds on this helper and adds the + ``x ∈ [x_min, x_max]`` domain bound plus a curvature-vs-sign check + that catches the "wrong region" case. Use ``tangent_lines`` directly + only when you need to compose the chord expressions manually (e.g. with + other linear terms, or without the domain bound). .. code-block:: python @@ -452,14 +497,13 @@ def tangent_lines( 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). + Breakpoint x-coordinates (must be strictly monotonic; both + ascending and descending are accepted). y_points : BreaksLike Breakpoint y-coordinates. @@ -559,6 +603,39 @@ def _check_strict_monotonicity(bp: DataArray) -> bool: return bool(monotonic.all()) +def _detect_convexity( + x_points: DataArray, y_points: DataArray +) -> Literal["convex", "concave", "linear", "mixed"]: + """ + Classify the shape of a single piecewise curve ``y = f(x)``. + + Invariant to whether breakpoints are listed ascending or descending in + x — same graph, same label. Multi-entity inputs are aggregated across + entities; to classify per entity, iterate at the call site (see + :data:`PWL_CONVEXITIES` for the possible labels). Callers must + enforce strict x-monotonicity per slice upstream. + """ + dx = x_points.diff(BREAKPOINT_DIM) + slopes = y_points.diff(BREAKPOINT_DIM) / dx + # Flip sign when x descends so the classification matches the + # ascending-x traversal. All dx in a strictly-monotonic slice share + # a sign, so the sum resolves direction per entity. + sd = slopes.diff(BREAKPOINT_DIM) * np.sign(dx.sum(BREAKPOINT_DIM)) + + if int((~sd.isnull()).sum()) == 0: + return "linear" + tol = 1e-10 + nonneg = bool(((sd >= -tol) | sd.isnull()).all()) + nonpos = bool(((sd <= tol) | sd.isnull()).all()) + if nonneg and nonpos: + return "linear" + if nonneg: + return "convex" + if nonpos: + return "concave" + return "mixed" + + def _has_trailing_nan_only(bp: DataArray) -> bool: """Check that NaN values only appear as trailing entries along BREAKPOINT_DIM.""" valid = ~bp.isnull() @@ -627,12 +704,13 @@ def _broadcast_points( def add_piecewise_formulation( model: Model, *pairs: tuple[LinExprLike, BreaksLike], - method: Literal["sos2", "incremental", "auto"] = "auto", + sign: Literal["==", "<=", ">="] = "==", + method: Literal["sos2", "incremental", "lp", "auto"] = "auto", active: LinExprLike | None = None, name: str | None = None, ) -> PiecewiseFormulation: r""" - Add piecewise linear equality constraints. + Add piecewise linear constraints. Each positional argument is a ``(expression, breakpoints)`` tuple. All expressions are linked through shared interpolation weights so @@ -654,21 +732,63 @@ def add_piecewise_formulation( (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. + **Sign — inequality bounds:** + + The ``sign`` parameter follows the *first-tuple convention*: + + - ``sign="=="`` (default): all expressions must lie exactly on the + piecewise curve (joint equality). + - ``sign="<="``: the **first** tuple's expression is **bounded above** + by its interpolated value; all other tuples are forced to equality + (inputs on the curve). Reads as *"first expression ≤ f(the rest)"*. + - ``sign=">="``: same but the first is bounded **below**. + + For 2-variable inequality on convex/concave curves, ``method="auto"`` + automatically selects a pure-LP tangent-line formulation (no auxiliary + variables). Non-convex curves fall back to SOS2/incremental with the + sign applied to the first tuple's link constraint. + + Example — ``fuel ≤ f(power)`` on a concave curve:: + + m.add_piecewise_formulation( + (fuel, y_pts), # bounded output, listed first + (power, x_pts), # input, always equality + sign="<=", + ) Parameters ---------- *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" + Each pair links an expression (Variable or LinearExpression) to + its breakpoint values. At least two pairs are required. With + ``sign != EQUAL`` the **first** pair is the bounded output; all + later pairs are treated as inputs forced to equality. + sign : {"==", "<=", ">="}, default "==" + Constraint sign applied to the *first* tuple's link constraint. + Later tuples always use equality. See description above. + method : {"auto", "sos2", "incremental", "lp"}, default "auto" Formulation method. + ``"lp"`` uses tangent lines (pure LP, no variables) and requires + ``sign != EQUAL`` plus a matching-convexity curve with exactly + two tuples. + ``"auto"`` picks ``"lp"`` when applicable, otherwise + ``"incremental"`` (monotonic breakpoints) or ``"sos2"``. active : Variable or LinearExpression, optional Binary variable that gates the piecewise function. When ``active=0``, all auxiliary variables are forced to zero. + Not supported with ``method="lp"``. + + With ``sign="=="`` (the default), the output is then pinned to + ``0``. With ``sign="<="`` / ``">="``, deactivation only pushes + the signed bound to ``0`` (the output is ≤ 0 or ≥ 0 + respectively) — the complementary bound still comes from the + output variable's own lower/upper. In the common case where + the output is naturally non-negative (fuel, cost, heat, …), + just set ``lower=0`` on that variable: combined with the + ``y ≤ 0`` constraint from deactivation, this forces ``y = 0`` + automatically. For outputs that genuinely need both signs you + must add the complementary bound yourself (e.g., a big-M + coupling ``y`` with ``active``). name : str, optional Base name for generated variables/constraints. @@ -676,10 +796,16 @@ def add_piecewise_formulation( ------- PiecewiseFormulation """ - if method not in ("sos2", "incremental", "auto"): - raise ValueError( - f"method must be 'sos2', 'incremental', or 'auto', got '{method}'" - ) + # Normalize sign (accept "==" or "=" for equality, etc.). The Literal + # annotation above covers the user-facing forms; after normalization + # ``sign`` holds one of the canonical values in :data:`SIGNS`. + sign = sign_replace_dict.get(sign, sign) # type: ignore[assignment] + if sign not in SIGNS: + raise ValueError(f"sign must be one of {sorted(SIGNS)}, got '{sign}'") + if method not in PWL_METHODS: + raise ValueError(f"method must be one of {sorted(PWL_METHODS)}, got '{method}'") + if method == "lp" and sign == EQUAL: + raise ValueError("method='lp' requires sign='<=' or '>='.") if len(pairs) < 2: raise TypeError( @@ -754,6 +880,10 @@ def add_piecewise_formulation( raise ValueError( "Incremental method is not supported for disjunctive constraints" ) + if method == "lp": + raise ValueError( + "method='lp' is not supported for disjunctive (segment) breakpoints" + ) _add_disjunctive( model, name, @@ -761,6 +891,7 @@ def add_piecewise_formulation( bp_list, link_coords, bp_mask, + sign, active_expr, ) resolved_method = "sos2" @@ -774,6 +905,7 @@ def add_piecewise_formulation( link_coords, bp_mask, method, + sign, active_expr, ) @@ -781,12 +913,32 @@ def add_piecewise_formulation( new_vars = [n for n in model.variables if n not in vars_before] new_cons = [n for n in model.constraints if n not in cons_before] + if method == "auto": + logger.info( + "piecewise formulation '%s': auto selected method='%s' " + "(sign='%s', %d pair%s)", + name, + resolved_method, + sign, + len(pairs), + "" if len(pairs) == 1 else "s", + ) + + # Compute convexity when well-defined: exactly two tuples (y, x), + # non-disjunctive, and strictly monotonic x breakpoints. + convexity: Literal["convex", "concave", "linear", "mixed"] | None = None + if len(bp_list) == 2 and not disjunctive: + x_pts, y_pts = bp_list[1], bp_list[0] + if _check_strict_monotonicity(x_pts): + convexity = _detect_convexity(x_pts, y_pts) + result = PiecewiseFormulation( name=name, method=resolved_method, variable_names=new_vars, constraint_names=new_cons, model=model, + convexity=convexity, ) model._piecewise_formulations[name] = result return result @@ -804,42 +956,203 @@ def _stack_along_link( return xr.concat(expanded, dim=link_dim, coords="minimal") # type: ignore -def _add_continuous( +def _lp_eligibility( + lin_exprs: list[LinearExpression], + bp_list: list[DataArray], + sign: str, + active: LinearExpression | None, +) -> tuple[bool, str]: + """ + Check whether LP tangent-lines dispatch is applicable. + + Returns ``(True, "")`` if LP is applicable, else ``(False, reason)`` + with a short string describing why. Used for both auto-dispatch + and for an informational log when LP is skipped. + """ + if len(lin_exprs) != 2: + return False, f"{len(lin_exprs)} expressions (LP supports only 2)" + if active is not None: + return False, "active=... is not supported by LP" + x_pts = bp_list[1] + y_pts = bp_list[0] + if not _check_strict_monotonicity(x_pts): + return False, "x breakpoints are not strictly monotonic" + if not _has_trailing_nan_only(x_pts): + return False, "x breakpoints contain non-trailing NaN" + convexity = _detect_convexity(x_pts, y_pts) + if sign == LESS_EQUAL and convexity not in ("concave", "linear"): + return False, f"sign='<=' needs concave/linear curvature, got '{convexity}'" + if sign == GREATER_EQUAL and convexity not in ("convex", "linear"): + return False, f"sign='>=' needs convex/linear curvature, got '{convexity}'" + return True, "" + + +@dataclass +class _PwlLinks: + """ + Packaged link expressions for a SOS2/incremental/disjunctive builder. + + ``stacked_bp`` spans *all* tuples — used to size lambda/delta variables. + ``eq_expr`` / ``eq_bp`` form the equality link (stacks all tuples when + ``sign == "=="``, inputs-only otherwise; may be ``None`` if there are no + inputs on the equality side). + ``signed_expr`` / ``signed_bp`` are the first tuple's output-side link + (``None`` iff ``sign == "=="``). + """ + + stacked_bp: DataArray + link_dim: str + bp_mask: DataArray | None + sign: str + eq_expr: LinearExpression | None + eq_bp: DataArray | None + signed_expr: LinearExpression | None + signed_bp: DataArray | None + + +def _build_links( model: Model, - name: str, lin_exprs: list[LinearExpression], bp_list: list[DataArray], link_coords: list[str], + link_dim: str, + sign: str, bp_mask: DataArray | None, - method: str, - active: LinearExpression | None = None, -) -> str: +) -> _PwlLinks: """ - Dispatch continuous piecewise equality to SOS2 or incremental. - - Returns the resolved method name ("sos2" or "incremental"). + Split (or stack) ``lin_exprs``/``bp_list`` into the equality and + signed link components dictated by ``sign``. """ from linopy.expressions import LinearExpression - link_dim = "_pwl_var" stacked_bp = _stack_along_link(bp_list, link_coords, link_dim) - # Pre-compute properties used by multiple branches + if sign == EQUAL: + eq_data = _stack_along_link([e.data for e in lin_exprs], link_coords, link_dim) + # eq_bp is deliberately aliased to stacked_bp here — all tuples are + # already on the equality side, so the "full stack" and the "equality + # stack" are the same array. + return _PwlLinks( + stacked_bp=stacked_bp, + link_dim=link_dim, + bp_mask=bp_mask, + sign=sign, + eq_expr=LinearExpression(eq_data, model), + eq_bp=stacked_bp, + signed_expr=None, + signed_bp=None, + ) + + signed_expr = lin_exprs[0] + signed_bp = bp_list[0] + inputs_exprs = lin_exprs[1:] + inputs_bp = bp_list[1:] + inputs_coords = link_coords[1:] + if inputs_exprs: + eq_data = _stack_along_link( + [e.data for e in inputs_exprs], inputs_coords, link_dim + ) + eq_expr: LinearExpression | None = LinearExpression(eq_data, model) + eq_bp: DataArray | None = _stack_along_link(inputs_bp, inputs_coords, link_dim) + else: + eq_expr = None + eq_bp = None + + return _PwlLinks( + stacked_bp=stacked_bp, + link_dim=link_dim, + bp_mask=bp_mask, + sign=sign, + eq_expr=eq_expr, + eq_bp=eq_bp, + signed_expr=signed_expr, + signed_bp=signed_bp, + ) + + +def _try_lp( + model: Model, + name: str, + lin_exprs: list[LinearExpression], + bp_list: list[DataArray], + method: str, + sign: str, + active: LinearExpression | None, +) -> bool: + """ + Dispatch the LP formulation if requested/eligible. + + Returns ``True`` when LP was built (caller should return ``"lp"``), + ``False`` when the caller should fall through to SOS2/incremental. + Raises on explicit ``method="lp"`` with mismatched inputs. + """ + if method == "lp": + if len(lin_exprs) != 2: + raise ValueError( + "method='lp' requires exactly 2 (expression, breakpoints) pairs." + ) + if active is not None: + raise ValueError("method='lp' is not compatible with active=...") + y_pts, x_pts = bp_list[0], bp_list[1] + if not _check_strict_monotonicity(x_pts): + raise ValueError("method='lp' requires strictly monotonic x breakpoints.") + convexity = _detect_convexity(x_pts, y_pts) + if sign == LESS_EQUAL and convexity not in ("concave", "linear"): + raise ValueError( + "method='lp' with sign='<=' requires concave or linear " + f"curvature; got '{convexity}'. Use method='auto'." + ) + if sign == GREATER_EQUAL and convexity not in ("convex", "linear"): + raise ValueError( + "method='lp' with sign='>=' requires convex or linear " + f"curvature; got '{convexity}'. Use method='auto'." + ) + _add_lp(model, name, lin_exprs[1], lin_exprs[0], x_pts, y_pts, sign) + return True + + if method == "auto" and sign != EQUAL: + ok, reason = _lp_eligibility(lin_exprs, bp_list, sign, active) + if ok: + _add_lp( + model, + name, + lin_exprs[1], + lin_exprs[0], + bp_list[1], + bp_list[0], + sign, + ) + return True + logger.info( + "piecewise formulation '%s': LP not applicable (%s); " + "will use SOS2/incremental instead", + name, + reason, + ) + return False + + +def _resolve_sos2_vs_incremental(method: str, stacked_bp: DataArray) -> str: + """ + Validate and (for ``method="auto"``) pick between SOS2 and + incremental based on monotonicity and NaN layout. + """ trailing_nan_only = _has_trailing_nan_only(stacked_bp) + is_monotonic = _check_strict_monotonicity(stacked_bp) + + if method == "auto": + return "incremental" if (is_monotonic and trailing_nan_only) else "sos2" - # Auto-detect method - if method in ("incremental", "auto"): - is_monotonic = _check_strict_monotonicity(stacked_bp) - if method == "auto": - method = "incremental" if (is_monotonic and trailing_nan_only) else "sos2" - elif not is_monotonic: + if method == "incremental": + if not is_monotonic: raise ValueError( "Incremental method requires strictly monotonic breakpoints." ) - if method == "incremental" and not trailing_nan_only: + if not trailing_nan_only: raise ValueError( "Incremental method does not support non-trailing NaN breakpoints." ) + return "incremental" if method == "sos2": _validate_numeric_breakpoint_coords(stacked_bp) @@ -847,107 +1160,111 @@ def _add_continuous( raise ValueError( "SOS2 method does not support non-trailing NaN breakpoints." ) + return "sos2" - # Stack expressions along the link dimension - stacked_data = _stack_along_link([e.data for e in lin_exprs], link_coords, link_dim) - target_expr = LinearExpression(stacked_data, model) + raise ValueError(f"unknown method {method!r}") - # 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 - ) - rhs = active if active is not None else 1 +def _add_continuous( + model: Model, + name: str, + lin_exprs: list[LinearExpression], + bp_list: list[DataArray], + link_coords: list[str], + bp_mask: DataArray | None, + method: str, + sign: str, + active: LinearExpression | None = None, +) -> str: + """ + Dispatch continuous piecewise constraints. + + Returns the resolved method name ("lp", "sos2", or "incremental"). + """ + link_dim = "_pwl_var" + + if _try_lp(model, name, lin_exprs, bp_list, method, sign, active): + return "lp" + + links = _build_links( + model, lin_exprs, bp_list, link_coords, link_dim, sign, bp_mask + ) + method = _resolve_sos2_vs_incremental(method, links.stacked_bp) if method == "sos2": - _add_sos2( - model, - name, - target_expr, - stacked_bp, - stacked_mask, - link_dim, - rhs, - ) - return method + rhs = active if active is not None else 1 + _add_sos2(model, name, links, rhs) else: - _add_incremental( - model, - name, - target_expr, - stacked_bp, - stacked_mask, - link_dim, - rhs, - active, - ) - return method + _add_incremental(model, name, links, active) + return method def _add_sos2( model: Model, name: str, - target_expr: LinearExpression, - stacked_bp: DataArray, - stacked_mask: DataArray | None, - link_dim: str, + links: _PwlLinks, rhs: LinearExpression | int, -) -> Constraint: - """SOS2 formulation for N-variable continuous piecewise equality.""" +) -> None: + """ + SOS2 formulation. ``links.eq_expr`` is the equality side; + ``links.signed_expr`` (if any) is the output-side link. + """ 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 + stacked_bp = links.stacked_bp + extra = _var_coords_from(stacked_bp, exclude={dim, links.link_dim}) 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_LINK_SUFFIX}" - lambda_var = model.add_variables( - lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=lambda_mask + lower=0, + upper=1, + coords=lambda_coords, + name=f"{name}{PWL_LAMBDA_SUFFIX}", + mask=links.bp_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) + model.add_constraints( + lambda_var.sum(dim=dim) == rhs, name=f"{name}{PWL_CONVEX_SUFFIX}" + ) + + if links.eq_expr is not None and links.eq_bp is not None: + input_weighted = (lambda_var * links.eq_bp).sum(dim=dim) + model.add_constraints( + links.eq_expr == input_weighted, name=f"{name}{PWL_LINK_SUFFIX}" + ) - weighted_sum = (lambda_var * stacked_bp).sum(dim=dim) - return model.add_constraints(target_expr == weighted_sum, name=link_name) + if links.signed_expr is not None and links.signed_bp is not None: + output_weighted = (lambda_var * links.signed_bp).sum(dim=dim) + _add_signed_link( + model, + links.signed_expr, + output_weighted, + links.sign, + f"{name}{PWL_OUTPUT_LINK_SUFFIX}", + ) def _add_incremental( model: Model, name: str, - target_expr: LinearExpression, - stacked_bp: DataArray, - stacked_mask: DataArray | None, - link_dim: str, - rhs: LinearExpression | int, + links: _PwlLinks, active: LinearExpression | None, -) -> Constraint: - """Incremental formulation for N-variable continuous piecewise equality.""" +) -> None: + """ + Incremental formulation. ``links.eq_expr`` is the equality side; + ``links.signed_expr`` (if any) is the output-side link. + """ dim = BREAKPOINT_DIM - extra = _var_coords_from(stacked_bp, exclude={dim, link_dim}) - - delta_name = f"{name}{PWL_DELTA_SUFFIX}" - fill_order_name = f"{name}{PWL_FILL_ORDER_SUFFIX}" - link_name = f"{name}{PWL_LINK_SUFFIX}" - order_binary_name = f"{name}{PWL_ORDER_BINARY_SUFFIX}" - delta_bound_name = f"{name}{PWL_DELTA_BOUND_SUFFIX}" - binary_order_name = f"{name}{PWL_BINARY_ORDER_SUFFIX}" + stacked_bp = links.stacked_bp + extra = _var_coords_from(stacked_bp, exclude={dim, links.link_dim}) 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}) + if links.bp_mask is not None: + mask_lo = links.bp_mask.isel({dim: slice(None, -1)}).rename({dim: seg_dim}) + mask_hi = links.bp_mask.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 @@ -955,32 +1272,62 @@ def _add_incremental( delta_mask = None delta_var = model.add_variables( - lower=0, upper=1, coords=delta_coords, name=delta_name, mask=delta_mask + lower=0, + upper=1, + coords=delta_coords, + name=f"{name}{PWL_DELTA_SUFFIX}", + 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) + model.add_constraints( + delta_var <= active, name=f"{name}{PWL_ACTIVE_BOUND_SUFFIX}" + ) binary_var = model.add_variables( - binary=True, coords=delta_coords, name=order_binary_name, mask=delta_mask + binary=True, + coords=delta_coords, + name=f"{name}{PWL_ORDER_BINARY_SUFFIX}", + mask=delta_mask, + ) + model.add_constraints( + delta_var <= binary_var, name=f"{name}{PWL_DELTA_BOUND_SUFFIX}" ) - model.add_constraints(delta_var <= binary_var, name=delta_bound_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_order_name) - + model.add_constraints( + delta_hi <= delta_lo, name=f"{name}{PWL_FILL_ORDER_SUFFIX}" + ) binary_hi = binary_var.isel({seg_dim: slice(1, None)}, drop=True) - model.add_constraints(binary_hi <= delta_lo, name=binary_order_name) + model.add_constraints( + binary_hi <= delta_lo, name=f"{name}{PWL_BINARY_ORDER_SUFFIX}" + ) - 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 _incremental_weighted(bp: DataArray) -> LinearExpression: + steps = bp.diff(dim).rename({dim: seg_dim}) + steps[seg_dim] = seg_index + bp0 = bp.isel({dim: 0}) + bp0_term: DataArray | LinearExpression = bp0 + if active is not None: + bp0_term = bp0 * active + return (delta_var * steps).sum(dim=seg_dim) + bp0_term + + if links.eq_expr is not None and links.eq_bp is not None: + model.add_constraints( + links.eq_expr == _incremental_weighted(links.eq_bp), + name=f"{name}{PWL_LINK_SUFFIX}", + ) + + if links.signed_expr is not None and links.signed_bp is not None: + _add_signed_link( + model, + links.signed_expr, + _incremental_weighted(links.signed_bp), + links.sign, + f"{name}{PWL_OUTPUT_LINK_SUFFIX}", + ) def _add_disjunctive( @@ -990,13 +1337,19 @@ def _add_disjunctive( bp_list: list[DataArray], link_coords: list[str], bp_mask: DataArray | None, + sign: str, active: LinearExpression | None = None, -) -> Constraint: - """Disjunctive SOS2 formulation for N-variable piecewise equality.""" - from linopy.expressions import LinearExpression - +) -> None: + """ + Disjunctive SOS2 formulation. Uses the shared ``_build_links`` + split: equality on inputs (all tuples when sign='=='), signed link + on the first tuple when sign != '=='. + """ link_dim = "_pwl_var" - stacked_bp = _stack_along_link(bp_list, link_coords, link_dim) + links = _build_links( + model, lin_exprs, bp_list, link_coords, link_dim, sign, bp_mask + ) + stacked_bp = links.stacked_bp _validate_numeric_breakpoint_coords(stacked_bp) if not _has_trailing_nan_only(stacked_bp): @@ -1005,17 +1358,6 @@ def _add_disjunctive( "NaN values must only appear at the end of the breakpoint sequence." ) - # 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) - - # 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 + [ @@ -1025,36 +1367,106 @@ def _add_disjunctive( binary_coords = extra + [ pd.Index(stacked_bp.coords[SEGMENT_DIM].values, name=SEGMENT_DIM), ] - - # 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_SEGMENT_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_LINK_SUFFIX}" + binary_mask = bp_mask.any(dim=dim) if bp_mask is not None else None binary_var = model.add_variables( - binary=True, coords=binary_coords, name=binary_name, mask=binary_mask + binary=True, + coords=binary_coords, + name=f"{name}{PWL_SEGMENT_BINARY_SUFFIX}", + mask=binary_mask, ) - 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=f"{name}{PWL_SELECT_SUFFIX}", + ) lambda_var = model.add_variables( - lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=lambda_mask + lower=0, + upper=1, + coords=lambda_coords, + name=f"{name}{PWL_LAMBDA_SUFFIX}", + mask=bp_mask, ) - model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=dim) + model.add_constraints( + lambda_var.sum(dim=dim) == binary_var, + name=f"{name}{PWL_CONVEX_SUFFIX}", + ) + + if links.eq_expr is not None and links.eq_bp is not None: + input_weighted = (lambda_var * links.eq_bp).sum(dim=[SEGMENT_DIM, dim]) + model.add_constraints( + links.eq_expr == input_weighted, name=f"{name}{PWL_LINK_SUFFIX}" + ) + + if links.signed_expr is not None and links.signed_bp is not None: + output_weighted = (lambda_var * links.signed_bp).sum(dim=[SEGMENT_DIM, dim]) + _add_signed_link( + model, + links.signed_expr, + output_weighted, + links.sign, + f"{name}{PWL_OUTPUT_LINK_SUFFIX}", + ) + + +def _add_signed_link( + model: Model, + lhs: LinearExpression, + rhs: LinearExpression, + sign: str, + name: str, + mask: DataArray | None = None, +) -> Constraint: + """Add a link constraint with the requested sign.""" + if sign == EQUAL: + return model.add_constraints(lhs == rhs, name=name, mask=mask) + elif sign == LESS_EQUAL: + return model.add_constraints(lhs <= rhs, name=name, mask=mask) + else: # ">=" + return model.add_constraints(lhs >= rhs, name=name, mask=mask) + + +def _add_lp( + model: Model, + name: str, + x_expr: LinearExpression, + y_expr: LinearExpression, + x_points: DataArray, + y_points: DataArray, + sign: str, +) -> None: + """ + LP tangent-line formulation (no auxiliary variables). - model.add_constraints(lambda_var.sum(dim=dim) == binary_var, name=convex_name) + Adds one chord constraint per segment plus domain bounds on x. + Trailing-NaN segments (per-entity short curves) are masked out so + they do not contribute spurious ``y ≤ 0`` constraints. + """ + # Per-segment validity: both endpoints must be non-NaN. + bp_valid = ~(x_points.isnull() | y_points.isnull()) + seg_count = x_points.sizes[BREAKPOINT_DIM] - 1 + seg_index = np.arange(seg_count) + full_mask = _rename_to_segments( + bp_valid.isel({BREAKPOINT_DIM: slice(None, -1)}) + & bp_valid.isel({BREAKPOINT_DIM: slice(1, None)}).values, + seg_index, + ) + seg_mask: DataArray | None = None if bool(full_mask.all()) else full_mask + + tangents = tangent_lines(x_expr, x_points, y_points) + _add_signed_link( + model, + y_expr, + tangents, + sign, + f"{name}{PWL_CHORD_SUFFIX}", + mask=seg_mask, + ) - weighted = (lambda_var * stacked_bp).sum(dim=[SEGMENT_DIM, dim]) - return model.add_constraints(target_expr == weighted, name=link_name) + # Domain bounds: x ∈ [x_min, x_max] (skipna by default). + x_min = x_points.min(dim=BREAKPOINT_DIM) + x_max = x_points.max(dim=BREAKPOINT_DIM) + model.add_constraints(x_expr >= x_min, name=f"{name}{PWL_DOMAIN_LO_SUFFIX}") + model.add_constraints(x_expr <= x_max, name=f"{name}{PWL_DOMAIN_HI_SUFFIX}") diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index b837d1a5..aef414b0 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from pathlib import Path import numpy as np @@ -22,13 +23,17 @@ LP_SEG_DIM, PWL_ACTIVE_BOUND_SUFFIX, PWL_BINARY_ORDER_SUFFIX, + PWL_CHORD_SUFFIX, PWL_CONVEX_SUFFIX, PWL_DELTA_BOUND_SUFFIX, PWL_DELTA_SUFFIX, + PWL_DOMAIN_HI_SUFFIX, + PWL_DOMAIN_LO_SUFFIX, PWL_FILL_ORDER_SUFFIX, PWL_LAMBDA_SUFFIX, PWL_LINK_SUFFIX, PWL_ORDER_BINARY_SUFFIX, + PWL_OUTPUT_LINK_SUFFIX, PWL_SEGMENT_BINARY_SUFFIX, PWL_SELECT_SUFFIX, SEGMENT_DIM, @@ -596,6 +601,27 @@ def test_three_variables(self) -> None: link = m.constraints[f"pwl0{PWL_LINK_SUFFIX}"] assert "_pwl_var" in [str(d) for d in link.dims] + @pytest.mark.skipif(not _sos2_solvers, reason="no SOS2-capable solver available") + def test_sign_le_respected_by_solver(self) -> None: + """ + Disjunctive + sign='<=' must actually bound the solved output + (not just structurally wire up the output link). + """ + m = Model() + x = m.add_variables(lower=0, upper=30, name="x") + y = m.add_variables(lower=0, upper=40, name="y") + # Two segments forming a concave profile: (0,0)→(10,20), (10,20)→(20,30) + m.add_piecewise_formulation( + (y, segments([[0.0, 20.0], [20.0, 30.0]])), + (x, segments([[0.0, 10.0], [10.0, 20.0]])), + sign="<=", + ) + m.add_constraints(x == 15) + m.add_objective(-y) # maximise y + m.solve() + # f(15) = 20 + (30-20)*0.5 = 25 + assert m.solution["y"].item() == pytest.approx(25.0, abs=1e-3) + # =========================================================================== # Validation @@ -1370,3 +1396,621 @@ def test_scalar_coord_dropped(self) -> None: method="sos2", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables + + +# =========================================================================== +# Sign parameter (inequality bounds) +# =========================================================================== + + +class TestSignParameter: + """Tests for sign="<=" / ">=" with the first-tuple convention.""" + + def test_default_is_equality(self) -> None: + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + m.add_piecewise_formulation((x, [0, 10, 50]), (y, [0, 5, 20])) + # no output_link for equality — single stacked link only + assert f"pwl0{PWL_OUTPUT_LINK_SUFFIX}" not in m.constraints + + def test_invalid_sign_raises(self) -> None: + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + with pytest.raises(ValueError, match="sign must be"): + m.add_piecewise_formulation((x, [0, 10]), (y, [0, 5]), sign="!") # type: ignore + + def test_lp_with_equality_raises(self) -> None: + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + with pytest.raises(ValueError, match="method='lp'"): + m.add_piecewise_formulation((x, [0, 10, 50]), (y, [0, 5, 20]), method="lp") + + def test_auto_picks_lp_for_concave_le(self) -> None: + """Concave curve + sign='<=' + auto → LP tangent lines (no aux vars).""" + m = Model() + power = m.add_variables(lower=0, upper=30, name="power") + fuel = m.add_variables(lower=0, upper=40, name="fuel") + # Concave: slopes 2, 1, 0.5 + m.add_piecewise_formulation( + (fuel, [0, 20, 30, 35]), + (power, [0, 10, 20, 30]), + sign="<=", + ) + assert f"pwl0{PWL_CHORD_SUFFIX}" in m.constraints + assert f"pwl0{PWL_DOMAIN_LO_SUFFIX}" in m.constraints + assert f"pwl0{PWL_DOMAIN_HI_SUFFIX}" in m.constraints + # No SOS2 lambdas for LP + assert f"pwl0{PWL_LAMBDA_SUFFIX}" not in m.variables + + def test_auto_picks_lp_for_convex_ge(self) -> None: + """Convex curve + sign='>=' + auto → LP tangent lines.""" + m = Model() + x = m.add_variables(lower=0, upper=30, name="x") + y = m.add_variables(lower=0, upper=100, name="y") + # Convex: slopes 1, 2, 3 + m.add_piecewise_formulation( + (y, [0, 10, 30, 60]), + (x, [0, 10, 20, 30]), + sign=">=", + ) + assert f"pwl0{PWL_CHORD_SUFFIX}" in m.constraints + + def test_auto_falls_back_to_sos2_for_nonmonotonic(self) -> None: + """Non-monotonic x + sign='<=' + auto → SOS2 with signed output link.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + # Non-monotonic x + m.add_piecewise_formulation( + (y, [0, 5, 2, 20]), + (x, [0, 10, 5, 50]), + sign="<=", + ) + assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables + assert f"pwl0{PWL_OUTPUT_LINK_SUFFIX}" in m.constraints + + def test_auto_concave_ge_falls_back_from_lp(self) -> None: + """Concave + sign='>=' is LP-loose → auto must not pick LP.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + f = m.add_piecewise_formulation( + (y, [0, 20, 30, 35]), # concave + (x, [0, 10, 20, 30]), + sign=">=", + ) + assert f.method != "lp" # fallback (sos2 or incremental) + + def test_auto_convex_le_falls_back_from_lp(self) -> None: + """Convex + sign='<=' is LP-loose → auto must not pick LP.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + f = m.add_piecewise_formulation( + (y, [0, 10, 30, 60]), # convex + (x, [0, 10, 20, 30]), + sign="<=", + ) + assert f.method != "lp" + + def test_lp_concave_ge_raises(self) -> None: + """Explicit LP + sign='>=' on concave curve is loose → raise.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + with pytest.raises(ValueError, match="convex"): + m.add_piecewise_formulation( + (y, [0, 20, 30, 35]), # concave + (x, [0, 10, 20, 30]), + sign=">=", + method="lp", + ) + + def test_lp_nonmatching_convexity_raises(self) -> None: + """Explicit LP with sign='<=' on a convex curve → error.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + # Convex curve, sign='<=' mismatch + with pytest.raises(ValueError, match="concave"): + m.add_piecewise_formulation( + (y, [0, 10, 30, 60]), # convex + (x, [0, 10, 20, 30]), + sign="<=", + method="lp", + ) + + def test_sos2_sign_le_has_output_link(self) -> None: + """Explicit SOS2 with sign='<=' gets a signed output link.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + m.add_piecewise_formulation( + (y, [0, 20, 30, 35]), + (x, [0, 10, 20, 30]), + sign="<=", + method="sos2", + ) + link = m.constraints[f"pwl0{PWL_OUTPUT_LINK_SUFFIX}"] + assert (link.sign == "<=").all().item() + + def test_incremental_sign_le(self) -> None: + """Incremental method honours sign on output link.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + m.add_piecewise_formulation( + (y, [0, 20, 30, 35]), + (x, [0, 10, 20, 30]), + sign="<=", + method="incremental", + ) + assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables + link = m.constraints[f"pwl0{PWL_OUTPUT_LINK_SUFFIX}"] + assert (link.sign == "<=").all().item() + + def test_nvar_inequality_bounds_first_tuple(self) -> None: + """N-variable: first tuple is bounded, others on curve.""" + m = Model() + fuel = m.add_variables(name="fuel") + power = m.add_variables(name="power") + heat = m.add_variables(name="heat") + m.add_piecewise_formulation( + (fuel, [0, 40, 85, 160]), # bounded + (power, [0, 30, 60, 100]), # input == + (heat, [0, 25, 55, 95]), # input == + sign="<=", + method="sos2", + ) + # inputs stacked, output signed + link = m.constraints[f"pwl0{PWL_LINK_SUFFIX}"] + output_link = m.constraints[f"pwl0{PWL_OUTPUT_LINK_SUFFIX}"] + assert "_pwl_var" in link.labels.dims # stacked inputs + assert "_pwl_var" not in output_link.labels.dims # single output + assert (output_link.sign == "<=").all().item() + + def test_lp_consistency_with_sos2(self) -> None: + """LP and SOS2 give the same fuel at a fixed power (within domain).""" + x_pts = [0, 10, 20, 30] + y_pts = [0, 20, 30, 35] # concave + + solutions = {} + for method in ["lp", "sos2", "incremental"]: + m = Model() + power = m.add_variables(lower=0, upper=30, name="power") + fuel = m.add_variables(lower=0, upper=40, name="fuel") + m.add_piecewise_formulation( + (fuel, y_pts), + (power, x_pts), + sign="<=", + method=method, + ) + m.add_constraints(power == 15) + m.add_objective(-fuel) # maximize fuel + m.solve() + solutions[method] = float(m.solution["fuel"]) + + # all methods should max out at f(15) = 25 + for method, val in solutions.items(): + assert abs(val - 25.0) < 1e-4, f"{method}: got {val}" + + def test_convexity_invariant_to_x_direction(self) -> None: + """Decreasing x must classify the same curve identically to ascending x.""" + m_asc = Model() + xa = m_asc.add_variables(name="x") + ya = m_asc.add_variables(name="y") + f_asc = m_asc.add_piecewise_formulation( + (ya, [0, 20, 30, 35]), + (xa, [0, 10, 20, 30]), + sign=">=", + ) + m_desc = Model() + xd = m_desc.add_variables(name="x") + yd = m_desc.add_variables(name="y") + f_desc = m_desc.add_piecewise_formulation( + (yd, [35, 30, 20, 0]), + (xd, [30, 20, 10, 0]), + sign=">=", + ) + assert f_asc.convexity == f_desc.convexity == "concave" + # concave + >= must fall back from LP + assert f_asc.method != "lp" + assert f_desc.method != "lp" + + def test_lp_per_entity_nan_padding(self) -> None: + """ + Per-entity NaN-padded breakpoints with method='lp': padded + segments must be masked out so they don't create spurious + ``y ≤ 0`` constraints (bug-2 regression). + """ + from linopy.piecewise import breakpoints + + bp_y = pd.DataFrame([[0, 20, 30, 35], [0, 10, 15, np.nan]], index=["a", "b"]) + bp_x = pd.DataFrame([[0, 10, 20, 30], [0, 5, 15, np.nan]], index=["a", "b"]) + results: dict[str, float] = {} + for method in ["lp", "sos2"]: + m = Model() + coord = pd.Index(["a", "b"], name="entity") + x = m.add_variables(lower=0, upper=20, coords=[coord], name="x") + y = m.add_variables(lower=0, upper=40, coords=[coord], name="y") + m.add_piecewise_formulation( + (y, breakpoints(bp_y, dim="entity")), + (x, breakpoints(bp_x, dim="entity")), + sign="<=", + method=method, + ) + m.add_constraints(x.sel(entity="b") == 10) + m.add_objective(-y.sel(entity="b")) + m.solve() + results[method] = float(m.solution.sel({"entity": "b"})["y"]) + # f_b(10) on chord (5,10)→(15,15) is 12.5 + assert abs(results["lp"] - 12.5) < 1e-3 + assert abs(results["sos2"] - results["lp"]) < 1e-3 + + def test_lp_rejects_decreasing_x_concave_ge(self) -> None: + """ + Explicit LP on a concave curve with sign='>=' must raise, even + when x is specified in decreasing order (bug-1 regression). + """ + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + with pytest.raises(ValueError, match="convex"): + m.add_piecewise_formulation( + (y, [35, 30, 20, 0]), # same concave curve + (x, [30, 20, 10, 0]), # decreasing x + sign=">=", + method="lp", + ) + + @pytest.mark.skipif(not _sos2_solvers, reason="no SOS2-capable solver available") + @pytest.mark.parametrize("method", ["sos2", "incremental"]) + def test_active_off_with_sign_le_leaves_lower_open(self, method: str) -> None: + """ + Documents the asymmetry between sign='==' and sign='<=' under + active=0: equality forces y=0, but '<=' only bounds y ≤ 0 — the + lower side still comes from the variable's own bounds. Verified + uniform across sos2 and incremental. A future change to add the + complementary bound automatically should flip this test. + """ + m = Model() + x = m.add_variables(lower=-100, upper=100, name="x") + y = m.add_variables(lower=-100, upper=100, name="y") + active = m.add_variables(binary=True, name="active") + m.add_piecewise_formulation( + (y, [0, 20, 30, 35]), + (x, [0, 10, 20, 30]), + sign="<=", + method=method, + active=active, + ) + m.add_constraints(active == 0) + m.add_objective(y) # minimize y + m.solve() + # y hits its own lower bound (not 0) — matches docstring note. + assert m.solution["y"].item() == pytest.approx(-100.0, abs=1e-6) + # Input x is still pinned to 0 by the equality input link. + assert m.solution["x"].item() == pytest.approx(0.0, abs=1e-6) + + @pytest.mark.skipif(not _sos2_solvers, reason="no SOS2-capable solver available") + def test_active_off_with_sign_le_and_lower_zero_pins_output(self) -> None: + """ + Docstring recipe: with ``y.lower = 0`` (the common case for + fuel/cost/heat outputs), the sign='<=' + active=0 asymmetry + disappears — the variable bound combined with y ≤ 0 forces + y = 0 automatically. + """ + m = Model() + x = m.add_variables(lower=0, upper=30, name="x") + y = m.add_variables(lower=0, upper=100, name="y") # the recipe + active = m.add_variables(binary=True, name="active") + m.add_piecewise_formulation( + (y, [0, 20, 30, 35]), + (x, [0, 10, 20, 30]), + sign="<=", + method="sos2", + active=active, + ) + m.add_constraints(active == 0) + m.add_objective(y, sense="max") # try to push y up + m.solve() + assert m.solution["y"].item() == pytest.approx(0.0, abs=1e-6) + + @pytest.mark.skipif(not _sos2_solvers, reason="no SOS2-capable solver available") + def test_active_off_with_sign_le_disjunctive(self) -> None: + """Same asymmetry applies to the disjunctive (segments) path.""" + m = Model() + x = m.add_variables(lower=-100, upper=100, name="x") + y = m.add_variables(lower=-100, upper=100, name="y") + active = m.add_variables(binary=True, name="active") + m.add_piecewise_formulation( + (y, segments([[0.0, 20.0], [20.0, 35.0]])), + (x, segments([[0.0, 10.0], [10.0, 30.0]])), + sign="<=", + active=active, + ) + m.add_constraints(active == 0) + m.add_objective(y) + m.solve() + assert m.solution["y"].item() == pytest.approx(-100.0, abs=1e-6) + assert m.solution["x"].item() == pytest.approx(0.0, abs=1e-6) + + def test_lp_active_explicit_raises(self) -> None: + """ + method='lp' + active is ValueError (silently ignoring active + would produce a wrong model). + """ + 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="active"): + m.add_piecewise_formulation( + (y, [0, 20, 30, 35]), + (x, [0, 10, 20, 30]), + sign="<=", + method="lp", + active=u, + ) + + def test_lp_accepts_linear_curve(self) -> None: + """ + A linear curve is both convex and concave per detection, so + LP must accept it with either sign and build the formulation. + """ + for sign in ["<=", ">="]: + m = Model() + x = m.add_variables(lower=0, upper=30, name="x") + y = m.add_variables(lower=0, upper=60, name="y") + f = m.add_piecewise_formulation( + (y, [0, 10, 20, 30]), # linear (all slopes = 1) + (x, [0, 10, 20, 30]), + sign=sign, + method="lp", + ) + assert f.method == "lp" + assert f.convexity == "linear" + + def test_auto_logs_when_lp_is_skipped( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """ + method='auto' on a non-LP-eligible case emits an INFO log + explaining why LP was passed over. + """ + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + with caplog.at_level(logging.INFO, logger="linopy.piecewise"): + m.add_piecewise_formulation( + (y, [0, 20, 30, 35]), # concave + sign='>=' → LP skipped + (x, [0, 10, 20, 30]), + sign=">=", + ) + assert "LP not applicable" in caplog.text + + @pytest.mark.skipif(not _any_solvers, reason="no solver available") + def test_lp_domain_bound_infeasible_when_x_out_of_range(self) -> None: + """ + LP's x ∈ [x_min, x_max] domain bound bites — forcing x beyond + the breakpoint range must make the model infeasible. + """ + m = Model() + x = m.add_variables(lower=0, upper=100, name="x") + y = m.add_variables(lower=0, upper=100, name="y") + m.add_piecewise_formulation( + (y, [0, 20, 30, 35]), + (x, [0, 10, 20, 30]), # x_max = 30 + sign="<=", + method="lp", + ) + m.add_constraints(x >= 50) + m.add_objective(-y) + status, _ = m.solve() + assert status != "ok" + + @pytest.mark.skipif(not _any_solvers, reason="no solver available") + def test_lp_matches_sos2_on_multi_dim_variables(self) -> None: + """ + LP with an entity dimension beyond BREAKPOINT_DIM must match + the SOS2 solution per entity. + """ + entities = pd.Index(["a", "b"], name="entity") + bp_x = pd.DataFrame([[0, 10, 20, 30], [0, 10, 20, 30]], index=["a", "b"]) + bp_y = pd.DataFrame([[0, 20, 30, 35], [0, 15, 25, 30]], index=["a", "b"]) + ys: dict[str, xr.DataArray] = {} + for method in ["lp", "sos2"]: + m = Model() + x = m.add_variables(lower=0, upper=30, coords=[entities], name="x") + y = m.add_variables(lower=0, upper=40, coords=[entities], name="y") + m.add_piecewise_formulation( + (y, breakpoints(bp_y, dim="entity")), + (x, breakpoints(bp_x, dim="entity")), + sign="<=", + method=method, + ) + m.add_constraints(x.sel(entity="a") == 15) + m.add_constraints(x.sel(entity="b") == 5) + m.add_objective(-y.sum()) + m.solve() + ys[method] = y.solution + for entity in ["a", "b"]: + assert float(ys["lp"].sel(entity=entity)) == pytest.approx( + float(ys["sos2"].sel(entity=entity)), abs=1e-3 + ) + + @pytest.mark.skipif(not _any_solvers, reason="no solver available") + def test_lp_consistency_with_sos2_both_directions(self) -> None: + """ + Extends test_lp_consistency_with_sos2 to also probe the + minimisation side of y ≤ f(x). + """ + x_pts = [0, 10, 20, 30] + y_pts = [0, 20, 30, 35] # concave + for obj_sign in [-1.0, +1.0]: + sols: dict[str, float] = {} + for method in ["lp", "sos2"]: + m = Model() + p = m.add_variables(lower=0, upper=30, name="p") + f = m.add_variables(lower=0, upper=50, name="f") + m.add_piecewise_formulation( + (f, y_pts), (p, x_pts), sign="<=", method=method + ) + m.add_constraints(p == 15) + m.add_objective(obj_sign * f) + m.solve() + sols[method] = float(m.solution["f"]) + assert sols["lp"] == pytest.approx(sols["sos2"], abs=1e-3) + + +def _bp(values: list[float]) -> xr.DataArray: + """Small helper: plain 1-D breakpoint DataArray for convexity tests.""" + return breakpoints(values) + + +class TestDetectConvexity: + """Direct unit tests for the _detect_convexity classifier.""" + + def test_convex(self) -> None: + from linopy.piecewise import _detect_convexity + + x = _bp([0, 1, 2, 3]) + y = _bp([0, 1, 4, 9]) # y = x^2 + assert _detect_convexity(x, y) == "convex" + + def test_concave(self) -> None: + from linopy.piecewise import _detect_convexity + + x = _bp([0, 1, 2, 3]) + y = _bp([0, 1, 1.5, 1.75]) # diminishing returns + assert _detect_convexity(x, y) == "concave" + + def test_linear_exact(self) -> None: + from linopy.piecewise import _detect_convexity + + x = _bp([0, 1, 2, 3]) + y = _bp([0, 2, 4, 6]) + assert _detect_convexity(x, y) == "linear" + + def test_linear_within_tol(self) -> None: + from linopy.piecewise import _detect_convexity + + # Tiny slope wobble within 1e-10 tolerance + x = _bp([0, 1, 2, 3]) + y = _bp([0, 2.0, 4.0 + 1e-12, 6.0 + 2e-12]) + assert _detect_convexity(x, y) == "linear" + + def test_mixed(self) -> None: + from linopy.piecewise import _detect_convexity + + x = _bp([0, 1, 2, 3, 4]) + y = _bp([0, 1, 4, 5, 4]) # convex then concave + assert _detect_convexity(x, y) == "mixed" + + def test_too_few_points_returns_linear(self) -> None: + from linopy.piecewise import _detect_convexity + + # Only two points — no second difference to examine + x = _bp([0, 1]) + y = _bp([0, 2]) + assert _detect_convexity(x, y) == "linear" + + def test_decreasing_x_matches_ascending(self) -> None: + """Reversing the breakpoint order must not change the label.""" + from linopy.piecewise import _detect_convexity + + # convex + assert _detect_convexity(_bp([0, 1, 2, 3]), _bp([0, 1, 4, 9])) == "convex" + assert _detect_convexity(_bp([3, 2, 1, 0]), _bp([9, 4, 1, 0])) == "convex" + # concave + assert ( + _detect_convexity(_bp([0, 10, 20, 30]), _bp([0, 20, 30, 35])) == "concave" + ) + assert ( + _detect_convexity(_bp([30, 20, 10, 0]), _bp([35, 30, 20, 0])) == "concave" + ) + + def test_trailing_nan_ignored(self) -> None: + from linopy.piecewise import _detect_convexity + + # Concave curve with a trailing NaN padding + x = _bp([0.0, 1.0, 2.0, np.nan]) + y = _bp([0.0, 1.0, 1.5, np.nan]) + assert _detect_convexity(x, y) == "concave" + + def test_multi_entity_same_shape(self) -> None: + from linopy.piecewise import _detect_convexity + + # Both rows convex + bp_x = pd.DataFrame([[0, 1, 2, 3], [0, 1, 2, 3]], index=["a", "b"]) + bp_y = pd.DataFrame([[0, 1, 4, 9], [0, 2, 8, 18]], index=["a", "b"]) + assert ( + _detect_convexity( + breakpoints(bp_x, dim="entity"), + breakpoints(bp_y, dim="entity"), + ) + == "convex" + ) + + def test_multi_entity_mixed_direction(self) -> None: + """Same concave curve, one entity ascending, one descending.""" + from linopy.piecewise import _detect_convexity + + bp_x = pd.DataFrame([[0, 10, 20, 30], [30, 20, 10, 0]], index=["a", "b"]) + bp_y = pd.DataFrame([[0, 20, 30, 35], [35, 30, 20, 0]], index=["a", "b"]) + assert ( + _detect_convexity( + breakpoints(bp_x, dim="entity"), + breakpoints(bp_y, dim="entity"), + ) + == "concave" + ) + + def test_multi_entity_mixed_curvatures(self) -> None: + """One convex, one concave across entities → mixed.""" + from linopy.piecewise import _detect_convexity + + bp_x = pd.DataFrame([[0, 1, 2, 3], [0, 1, 2, 3]], index=["a", "b"]) + bp_y = pd.DataFrame([[0, 1, 4, 9], [0, 1, 1.5, 1.75]], index=["a", "b"]) + assert ( + _detect_convexity( + breakpoints(bp_x, dim="entity"), + breakpoints(bp_y, dim="entity"), + ) + == "mixed" + ) + + +# =========================================================================== +# netCDF round-trip +# =========================================================================== + + +class TestPiecewiseNetCDFRoundtrip: + def test_formulation_survives_netcdf(self, tmp_path: Path) -> None: + from linopy import read_netcdf + from linopy.piecewise import PiecewiseFormulation + + m = Model() + y = m.add_variables(name="y") + x = m.add_variables(lower=0, upper=30, name="x") + f = m.add_piecewise_formulation( + (y, [0, 20, 30, 35]), + (x, [0, 10, 20, 30]), + name="pwl", + ) + assert f.convexity == "concave" + + path = tmp_path / "model.nc" + m.to_netcdf(path) + f2 = read_netcdf(path)._piecewise_formulations["pwl"] + + # Compare every slot except the back-reference to the model, so this + # test auto-catches any future field that IO forgets to persist. + fields = [s for s in PiecewiseFormulation.__slots__ if s != "_model"] + before = {s: getattr(f, s) for s in fields} + after = {s: getattr(f2, s) for s in fields} + assert before == after From 1f6b563047550d36d45c49a990e25a94202ff561 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:23:23 +0200 Subject: [PATCH 34/65] feat(piecewise): post-#663 strategic tests, docs, and EvolvingAPIWarning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashes 17 commits of follow-up work on top of the #663 merge into this branch. Tests (test/test_piecewise_feasibility.py — new, 400+ lines): - Strategic feasibility-region equivalence. The strong test is TestRotatedObjective: for every rotation (α, β) on the unit circle, the support function min α·x + β·y under the PWL must match a vertex-enumeration oracle. Equal support functions over a dense direction set imply equal convex feasible regions. - Additional classes: TestDomainBoundary (x outside the breakpoint range is infeasible under all methods), TestPointwiseInfeasibility (y nudged past f(x) is infeasible), TestHandComputedAnchors (arithmetically trivial expected values that sanity-check the oracle itself), and TestNVariableInequality hardened with a 3-D rotated oracle, heat-off-curve infeasibility, and interior-point feasibility. - Curve dataclass + CURVES list covering concave/convex/linear/ two-segment/offset variants. Method/Sign/MethodND literal aliases for mypy-tight fixture and loop typing. - ~406 pytest items, ~30s runtime, TOL = 1e-5 globally. Tests (test/test_piecewise_constraints.py): - Hardened TestDisjunctive with sign_le_hits_correct_segment (six x-values across two segments with different slopes) and sign_le_in_forbidden_zone_infeasible. Confirms the binary-select + signed-output-link combination routes each x to the right segment's interpolation. - Local Method/Sign literal aliases so the existing loop-over-methods tests survive the tightened add_piecewise_formulation signature. EvolvingAPIWarning: - New linopy.EvolvingAPIWarning(FutureWarning) — visible by default, subclass so users can filter it precisely without affecting other FutureWarnings. Added to __all__ and re-exported at top level. - Emitted from add_piecewise_formulation and tangent_lines with a "piecewise:" message prefix. Every message points users at https://github.com/PyPSA/linopy/issues so feedback shapes what stabilises. - tangent_lines split into a public wrapper (warns) and a private _tangent_lines_impl (no warn) so _add_lp doesn't double-fire. - Message-based filter in pyproject.toml (``"ignore:piecewise:FutureWarning"``) avoids forcing pytest to import linopy at config-parse time (which broke --doctest-modules collection on Windows via a site-packages vs source-tree module clash). Docs: - doc/piecewise-linear-constraints.rst: soften "sign unlocks LP" to reflect that disjunctive + sign is always exact regardless of curvature. New paragraph in the Disjunctive Methods subsection positioning it as a first-class tool for "bounded output on disconnected operating regions". - doc/release_notes.rst: update the piecewise bullet to mention the EvolvingAPIWarning, how to silence it, and the feedback URL. - dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb (new, gitignored→force-added for PR review): visual explanation of each test class — 16-direction probes + extreme points, domain-boundary probes, pointwise nudge, 3-D CHP ribbon. Dropped before master merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...cewise-feasibility-tests-walkthrough.ipynb | 348 +++++++++++ doc/piecewise-linear-constraints.rst | 23 +- doc/release_notes.rst | 2 +- linopy/__init__.py | 8 +- linopy/constants.py | 26 + linopy/piecewise.py | 104 +++- pyproject.toml | 12 + test/test_piecewise_constraints.py | 76 ++- test/test_piecewise_feasibility.py | 587 ++++++++++++++++++ 9 files changed, 1150 insertions(+), 36 deletions(-) create mode 100644 dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb create mode 100644 test/test_piecewise_feasibility.py diff --git a/dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb b/dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb new file mode 100644 index 00000000..b2fdaf7c --- /dev/null +++ b/dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb @@ -0,0 +1,348 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# `test_piecewise_feasibility.py` — visual walkthrough\n", + "\n", + "**Purpose:** document what each test class in `test/test_piecewise_feasibility.py` actually probes, with pictures. Intended as review aid for the PR — **not** merged into master.\n", + "\n", + "The test file stress-tests the claim that `add_piecewise_formulation(sign=\"<=\"/\">=\")` yields the **same feasible region** for `(x, y)` regardless of which method (`lp` / `sos2` / `incremental`) dispatches the formulation, on curves where all three are applicable.\n", + "\n", + "Four test classes:\n", + "\n", + "| class | what it probes | scope |\n", + "|---|---|---|\n", + "| `TestRotatedObjective` | support-function equivalence — 16 rotation directions | the strong test |\n", + "| `TestDomainBoundary` | `x` outside `[x_min, x_max]` is infeasible | LP explicit vs SOS2 implicit |\n", + "| `TestPointwiseInfeasibility` | `y` just past `f(x)` is infeasible | targeted sanity check |\n", + "| `TestNVariableInequality` | 3-variable: first tuple bounded, rest equality | SOS2 vs incremental only |\n", + "\n", + "Below: one visualization per class.\n", + "\n", + "*Run this notebook from the repository root so that `from test.test_piecewise_feasibility import ...` resolves.*" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-23T08:00:47.376525Z", + "start_time": "2026-04-23T08:00:46.142492Z" + } + }, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "from test.test_piecewise_feasibility import (\n", + " CURVES,\n", + " Y_HI,\n", + " Y_LO,\n", + " Curve,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Shared primitive: draw the curve and its feasible region" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-23T08:00:47.384959Z", + "start_time": "2026-04-23T08:00:47.381361Z" + } + }, + "outputs": [], + "source": [ + "def draw_curve_and_region(ax, curve: Curve, *, shade: bool = True) -> None:\n", + " \"\"\"Plot breakpoints + shade the feasible region (hypograph or epigraph).\"\"\"\n", + " xs = np.array(curve.x_pts)\n", + " ys = np.array(curve.y_pts)\n", + " ax.plot(xs, ys, \"o-\", color=\"C0\", lw=2, label=\"breakpoints\")\n", + "\n", + " if shade:\n", + " if curve.sign == \"<=\":\n", + " ax.fill_between(\n", + " xs,\n", + " np.full_like(ys, Y_LO),\n", + " ys,\n", + " alpha=0.15,\n", + " color=\"C0\",\n", + " label=f\"feasible: y {curve.sign} f(x)\",\n", + " )\n", + " else:\n", + " ax.fill_between(\n", + " xs,\n", + " ys,\n", + " np.full_like(ys, Y_HI),\n", + " alpha=0.15,\n", + " color=\"C0\",\n", + " label=f\"feasible: y {curve.sign} f(x)\",\n", + " )\n", + "\n", + " pad_x = 0.15 * (xs.max() - xs.min())\n", + " pad_y = 0.15 * (ys.max() - ys.min()) + 1\n", + " ax.set_xlim(xs.min() - pad_x, xs.max() + pad_x)\n", + " ax.set_ylim(ys.min() - pad_y, ys.max() + pad_y)\n", + " ax.set_xlabel(\"x\")\n", + " ax.set_ylabel(\"y\")\n", + " ax.grid(alpha=0.3)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## `TestRotatedObjective` — the strong test\n", + "\n", + "For every direction `(α, β)` on the unit circle, minimize `α·x + β·y` under the PWL. The answer is the **support function** of the feasible region in direction `(α, β)` — and for a convex region, the support function uniquely determines the region. If LP and SOS2/incremental give the same support-function value for 16 directions, their feasible regions are identical.\n", + "\n", + "Each red dot below is the extreme point the solver lands at for one direction. The arrows show the objective-push direction. A failure would manifest as one method's dot landing at a different vertex than the oracle's." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-23T08:00:47.829483Z", + "start_time": "2026-04-23T08:00:47.388542Z" + } + }, + "outputs": [], + "source": [ + "def panel_rotated_objective(ax, curve: Curve, n_dirs: int = 16) -> None:\n", + " draw_curve_and_region(ax, curve)\n", + " xs, ys = np.array(curve.x_pts), np.array(curve.y_pts)\n", + " cx = 0.5 * (xs.min() + xs.max())\n", + " cy = 0.5 * (ys.min() + ys.max())\n", + " arrow_len = 0.25 * min(xs.max() - xs.min(), (ys.max() - ys.min()) + 5)\n", + "\n", + " for i in range(n_dirs):\n", + " theta = 2 * np.pi * i / n_dirs\n", + " alpha, beta = np.cos(theta), np.sin(theta)\n", + " ax.annotate(\n", + " \"\",\n", + " xytext=(cx, cy),\n", + " xy=(cx + arrow_len * alpha, cy + arrow_len * beta),\n", + " arrowprops=dict(arrowstyle=\"->\", color=\"C3\", alpha=0.4, lw=1),\n", + " )\n", + " # Oracle extreme point in this direction\n", + " verts = curve.vertices()\n", + " extreme = min(verts, key=lambda v: alpha * v[0] + beta * v[1])\n", + " ax.plot(*extreme, \"o\", color=\"C3\", ms=4, alpha=0.7)\n", + "\n", + " ax.plot([], [], \"o\", color=\"C3\", alpha=0.7, label=f\"{n_dirs} extreme points\")\n", + " ax.legend(loc=\"upper left\", fontsize=8)\n", + " ax.set_title(f\"{curve.name} (sign={curve.sign})\")\n", + "\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))\n", + "panel_rotated_objective(axes[0], CURVES[0]) # concave-smooth\n", + "panel_rotated_objective(axes[1], CURVES[2]) # convex-steep\n", + "panel_rotated_objective(axes[2], CURVES[5]) # two-segment\n", + "fig.suptitle(\n", + " \"TestRotatedObjective — support function sampled at 16 directions\", fontsize=12\n", + ")\n", + "plt.tight_layout();" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Notice the **dots cluster at the curve breakpoints** (top edges) and at the **bottom corners** `(x_min, Y_LO)`, `(x_max, Y_LO)`. That's because the feasible region is a polygon: linear objectives always attain their optimum at a vertex.\n", + "\n", + "The 288 pytest items (6 curves × 3 methods × 16 directions) check that all three methods land at the same extreme point for every direction." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## `TestDomainBoundary` — enforce `x ∈ [x_min, x_max]`\n", + "\n", + "LP enforces this with an explicit constraint; SOS2/incremental enforce it implicitly via `sum(λ) = 1`. Two different implementations of the same bound — worth a direct probe." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-23T08:00:48.103641Z", + "start_time": "2026-04-23T08:00:47.835275Z" + } + }, + "outputs": [], + "source": [ + "def panel_domain_boundary(ax, curve: Curve) -> None:\n", + " draw_curve_and_region(ax, curve)\n", + " xs = np.array(curve.x_pts)\n", + " y_span = ax.get_ylim()\n", + " ax.axvline(xs[0], color=\"C2\", lw=1.5, label=f\"x_min={xs[0]}\")\n", + " ax.axvline(xs[-1], color=\"C2\", lw=1.5, label=f\"x_max={xs[-1]}\")\n", + " ax.axvline(xs[0] - 1, color=\"C3\", lw=1.5, ls=\"--\")\n", + " ax.axvline(xs[-1] + 1, color=\"C3\", lw=1.5, ls=\"--\")\n", + " yy = y_span[1] - 0.12 * (y_span[1] - y_span[0])\n", + " ax.text(\n", + " xs[0] - 1, yy, \"INFEASIBLE\\n(x < x_min)\", ha=\"center\", fontsize=8, color=\"C3\"\n", + " )\n", + " ax.text(\n", + " xs[-1] + 1, yy, \"INFEASIBLE\\n(x > x_max)\", ha=\"center\", fontsize=8, color=\"C3\"\n", + " )\n", + " ax.legend(loc=\"lower center\", fontsize=7)\n", + " ax.set_title(f\"{curve.name} — domain probe\")\n", + "\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))\n", + "panel_domain_boundary(axes[0], CURVES[0]) # concave-smooth\n", + "panel_domain_boundary(axes[1], CURVES[1]) # concave-shifted (negative domain)\n", + "panel_domain_boundary(axes[2], CURVES[5]) # two-segment\n", + "fig.suptitle(\n", + " \"TestDomainBoundary — x outside the breakpoint range is infeasible\", fontsize=12\n", + ")\n", + "plt.tight_layout();" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## `TestPointwiseInfeasibility` — y just past the curve\n", + "\n", + "Rotated objectives probe *extremes*; this test specifically nudges `y` past `f(x)` by a small margin (`0.01`) and asserts infeasibility. Catches NaN-mask or off-by-one-segment bugs that might accidentally allow slack." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-23T08:00:48.366674Z", + "start_time": "2026-04-23T08:00:48.112127Z" + } + }, + "outputs": [], + "source": [ + "def panel_pointwise(ax, curve: Curve) -> None:\n", + " draw_curve_and_region(ax, curve)\n", + " xs = np.array(curve.x_pts)\n", + " x_mid = 0.5 * (xs[0] + xs[-1])\n", + " fx = curve.f(x_mid)\n", + " y_bad = fx + 0.01 if curve.sign == \"<=\" else fx - 0.01\n", + " ax.plot(x_mid, fx, \"o\", color=\"C2\", ms=9, label=f\"on curve: f({x_mid:g})={fx:g}\")\n", + " ax.plot(\n", + " x_mid, y_bad, \"x\", color=\"C3\", ms=14, mew=3, label=f\"infeasible: y={y_bad:g}\"\n", + " )\n", + " ax.legend(loc=\"lower right\", fontsize=7)\n", + " ax.set_title(f\"{curve.name} — nudge past f(x)\")\n", + "\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))\n", + "panel_pointwise(axes[0], CURVES[0]) # concave-smooth, sign=\"<=\"\n", + "panel_pointwise(axes[1], CURVES[2]) # convex-steep, sign=\">=\"\n", + "panel_pointwise(axes[2], CURVES[4]) # linear-gte\n", + "fig.suptitle(\n", + " \"TestPointwiseInfeasibility — y past the curve by 0.01 in the sign direction\",\n", + " fontsize=12,\n", + ")\n", + "plt.tight_layout();" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## `TestNVariableInequality` — 3-variable sign split\n", + "\n", + "With three tuples `(fuel, power, heat)` and `sign=\"<=\"`:\n", + "- `fuel` (the **first** tuple) is **bounded above** by its interpolated value,\n", + "- `power` and `heat` (remaining tuples) are **forced to equality** — pinned on the curve.\n", + "\n", + "LP doesn't support N > 2 tuples, so this class compares SOS2 vs incremental only. The 3D plot shows the CHP curve and the 7 test points (one per `power_fix`) that both methods must agree on." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-23T08:00:48.489668Z", + "start_time": "2026-04-23T08:00:48.371526Z" + } + }, + "outputs": [], + "source": [ + "bp = {\n", + " \"power\": np.array([0, 30, 60, 100]),\n", + " \"fuel\": np.array([0, 40, 85, 160]),\n", + " \"heat\": np.array([0, 25, 55, 95]),\n", + "}\n", + "\n", + "fig = plt.figure(figsize=(9, 6.5))\n", + "ax = fig.add_subplot(projection=\"3d\")\n", + "ax.plot(\n", + " bp[\"power\"], bp[\"fuel\"], bp[\"heat\"], \"o-\", color=\"C0\", lw=2, label=\"CHP breakpoints\"\n", + ")\n", + "\n", + "for p in [0, 15, 30, 45, 60, 80, 100]:\n", + " f = np.interp(p, bp[\"power\"], bp[\"fuel\"])\n", + " h = np.interp(p, bp[\"power\"], bp[\"heat\"])\n", + " ax.plot([p], [f], [h], \"o\", color=\"C3\", ms=7)\n", + " # drop to base plane\n", + " ax.plot([p, p], [f, 0], [h, h], color=\"C3\", alpha=0.3, lw=0.8)\n", + "\n", + "ax.set_xlabel(\"power\")\n", + "ax.set_ylabel(\"fuel\")\n", + "ax.set_zlabel(\"heat\")\n", + "ax.plot(\n", + " [],\n", + " [],\n", + " \"o\",\n", + " color=\"C3\",\n", + " label=\"7 test points — power pinned,\\nfuel at upper bound, heat on curve\",\n", + ")\n", + "ax.set_title('TestNVariableInequality — CHP curve (sign=\"<=\")')\n", + "ax.legend(loc=\"upper left\", fontsize=8);" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## What a failing test would tell you\n", + "\n", + "- **Rotated objective fails**: the methods disagree on the feasible region in some direction. The failure message includes the attained `(x, y)` point — you'd see which extreme point one method landed at that the others didn't.\n", + "- **Domain boundary fails**: one method lets `x` escape `[x_min, x_max]`. LP path most likely: the domain-bound constraint was dropped. SOS2 path: the `sum(λ) = 1` constraint was weakened.\n", + "- **Pointwise infeasibility fails**: one method accepts a point past the curve. Most often a NaN-mask bug in per-entity formulations, or a wrong segment getting picked.\n", + "- **N-variable fails**: the sign split went wrong — either an input leaked into the signed link or the first-tuple convention is misrouting.\n", + "\n", + "All 356 pytest items are currently green at `TOL = 1e-5`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.13.2" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 8114a6a4..d2c150fd 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -103,10 +103,16 @@ one designated output; the other tuples parameterise the curve. **When is a one-sided bound wanted?** -The primary reason to reach for ``sign="<="`` / ``">="`` is to unlock the -**LP chord formulation** — no SOS2, no binaries, just pure LP. On a -convex/concave curve with a matching sign, the chord inequalities are as -tight as SOS2, so you get the same optimum with a cheaper model. +For *continuous* curves, the main reason to reach for ``sign="<="`` / +``">="`` is to unlock the **LP chord formulation** — no SOS2, no +binaries, just pure LP. On a convex/concave curve with a matching sign, +the chord inequalities are as tight as SOS2, so you get the same optimum +with a cheaper model. + +For *disjunctive* curves (``segments(...)``), ``sign`` is a first-class +tool in its own right: disconnected operating regions with a bounded +output, always exact regardless of segment curvature (see the +disjunctive section below). Beyond that: fuel-on-efficiency-envelope modelling (extra burn above the curve is admissible, cost is still bounded), emissions caps where the curve @@ -377,6 +383,15 @@ indicators :math:`z_k` select exactly one segment; SOS2 applies within it: No big-M constants are needed, giving a tight LP relaxation. +**Disjunctive + ``sign``.** ``sign="<="`` / ``">="`` works here too, +applied to the first tuple exactly as for the continuous methods. +Because the disjunctive machinery already carries a per-segment binary, +there is **no curvature requirement** on the segments — inequality is +always exact on the hypograph (or epigraph) of the active segment, +whatever its slope pattern. This makes disjunctive + sign a first-class +tool for "bounded output on disconnected operating regions" that +``method="lp"`` cannot handle. + Advanced Features ----------------- diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 3df9e25e..621ea0ca 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -11,7 +11,7 @@ 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_formulation()`` for piecewise linear equality constraints with SOS2, incremental, and disjunctive formulations: ``m.add_piecewise_formulation((power, x_pts), (fuel, y_pts))``. Supports N-variable linking (e.g. CHP with fuel/power/heat) and per-entity breakpoints. ``method="auto"`` picks the cheapest correct formulation automatically. +* Add ``add_piecewise_formulation()`` for piecewise linear equality constraints with SOS2, incremental, and disjunctive formulations: ``m.add_piecewise_formulation((power, x_pts), (fuel, y_pts))``. Supports N-variable linking (e.g. CHP with fuel/power/heat) and per-entity breakpoints. ``method="auto"`` picks the cheapest correct formulation automatically. The API is newly added and emits an :class:`linopy.EvolvingAPIWarning` to signal that details (e.g. the ``sign``/first-tuple convention, ``active`` + non-equality sign semantics) may be refined in minor releases — feedback and use cases at https://github.com/PyPSA/linopy/issues shape what stabilises. Silence with ``warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)``. * Add one-sided piecewise bounds via the ``sign`` parameter on ``add_piecewise_formulation``: ``sign="<="`` / ``">="`` applies the bound to the first tuple (first-tuple convention). On convex/concave curves with a matching sign, ``method="auto"`` dispatches to a pure-LP chord formulation (``method="lp"``) with no auxiliary variables and automatic domain bounds on the input. Mismatched curvature+sign is detected and falls back to SOS2/incremental with an explanatory info log. * Add unit-commitment gating via the ``active`` parameter on ``add_piecewise_formulation``: a binary variable that, when zero, forces all auxiliary variables (and thus the linked expressions) to zero. Works with the SOS2, incremental, and disjunctive methods. * Surface formulation metadata on the returned ``PiecewiseFormulation``: ``.method`` (resolved method name) and ``.convexity`` (``"convex"`` / ``"concave"`` / ``"linear"`` / ``"mixed"`` when well-defined). Both persist across netCDF round-trip. diff --git a/linopy/__init__.py b/linopy/__init__.py index fb54c6c4..09c23b7e 100644 --- a/linopy/__init__.py +++ b/linopy/__init__.py @@ -14,7 +14,12 @@ import linopy.monkey_patch_xarray # noqa: F401 from linopy.common import align from linopy.config import options -from linopy.constants import EQUAL, GREATER_EQUAL, LESS_EQUAL +from linopy.constants import ( + EQUAL, + GREATER_EQUAL, + LESS_EQUAL, + EvolvingAPIWarning, +) from linopy.constraints import Constraint, Constraints from linopy.expressions import LinearExpression, QuadraticExpression, merge from linopy.io import read_netcdf @@ -38,6 +43,7 @@ "Constraint", "Constraints", "EQUAL", + "EvolvingAPIWarning", "GREATER_EQUAL", "LESS_EQUAL", "LinearExpression", diff --git a/linopy/constants.py b/linopy/constants.py index b8aef6ef..461f7895 100644 --- a/linopy/constants.py +++ b/linopy/constants.py @@ -79,6 +79,32 @@ SOS_BIG_M_ATTR = "big_m_upper" +class EvolvingAPIWarning(FutureWarning): + """ + Signals a newly-added API whose details may evolve in minor releases. + + Subclasses :class:`FutureWarning` so it is visible by default. Each + emit prefixes its message with the affected feature (e.g. + ``"piecewise: ..."``) so message-regex filters can target a single + feature without hiding warnings from other features. + + Silence globally with:: + + import warnings + import linopy + + warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning) + + Or only one feature:: + + warnings.filterwarnings( + "ignore", + category=linopy.EvolvingAPIWarning, + message=r"^piecewise:", + ) + """ + + class ModelStatus(Enum): """ Model status. diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 46483d31..313f7a0a 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +import warnings from collections.abc import Sequence from dataclasses import dataclass from numbers import Real @@ -43,6 +44,7 @@ PWL_SELECT_SUFFIX, SEGMENT_DIM, SIGNS, + EvolvingAPIWarning, sign_replace_dict, ) @@ -471,6 +473,43 @@ def segments( return _coerce_segments(values, dim) +def _tangent_lines_impl( + x: LinExprLike, + x_points: BreaksLike, + y_points: BreaksLike, +) -> LinearExpression: + """ + Chord-expression math — the body of ``tangent_lines`` without the + :class:`EvolvingAPIWarning`. Called internally by ``_add_lp`` so a + single ``add_piecewise_formulation(sign="<=")`` emits exactly one + warning, not two. + """ + from linopy.expressions import LinearExpression as LinExpr + from linopy.variables import Variable + + x_points = _coerce_breaks(x_points) + y_points = _coerce_breaks(y_points) + + dx = x_points.diff(BREAKPOINT_DIM) + dy = y_points.diff(BREAKPOINT_DIM) + seg_index = np.arange(dx.sizes[BREAKPOINT_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 = _rename_to_segments( + y_points.isel({BREAKPOINT_DIM: slice(None, -1)}), seg_index + ) + + intercepts = y_base - slopes * x_base + + if not isinstance(x, Variable | LinExpr): + raise TypeError(f"x must be a Variable or LinearExpression, got {type(x)}") + + return slopes * _to_linexpr(x) + intercepts + + def tangent_lines( x: LinExprLike, x_points: BreaksLike, @@ -512,31 +551,26 @@ def tangent_lines( LinearExpression Expression with an additional ``_breakpoint_seg`` dimension (one entry per segment). - """ - from linopy.expressions import LinearExpression as LinExpr - from linopy.variables import Variable - - x_points = _coerce_breaks(x_points) - y_points = _coerce_breaks(y_points) - dx = x_points.diff(BREAKPOINT_DIM) - dy = y_points.diff(BREAKPOINT_DIM) - seg_index = np.arange(dx.sizes[BREAKPOINT_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 = _rename_to_segments( - y_points.isel({BREAKPOINT_DIM: slice(None, -1)}), seg_index + Warns + ----- + EvolvingAPIWarning + ``tangent_lines`` is part of the newly-added piecewise API; the + returned expression shape and segment-dim name may be refined. + Silence with ``warnings.filterwarnings("ignore", + category=linopy.EvolvingAPIWarning)``. + """ + warnings.warn( + "piecewise: tangent_lines is a new API; the returned expression " + "shape and the segment-dim name may be refined in minor releases. " + "Please share your use cases or concerns at " + "https://github.com/PyPSA/linopy/issues — your feedback shapes " + "what stabilises. Silence with " + '`warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.', + category=EvolvingAPIWarning, + stacklevel=2, ) - - intercepts = y_base - slopes * x_base - - if not isinstance(x, Variable | LinExpr): - raise TypeError(f"x must be a Variable or LinearExpression, got {type(x)}") - - return slopes * _to_linexpr(x) + intercepts + return _tangent_lines_impl(x, x_points, y_points) # --------------------------------------------------------------------------- @@ -795,7 +829,27 @@ def add_piecewise_formulation( Returns ------- PiecewiseFormulation + + Warns + ----- + EvolvingAPIWarning + ``add_piecewise_formulation`` is a newly-added API; details such + as the ``sign``/first-tuple convention and ``active`` + non-equality + sign semantics may be refined based on user feedback. Silence + with ``warnings.filterwarnings("ignore", + category=linopy.EvolvingAPIWarning)``. """ + warnings.warn( + "piecewise: add_piecewise_formulation is a new API; some details " + "(e.g. the sign/first-tuple convention, active+sign semantics) " + "may be refined in minor releases. Please share your use cases " + "or concerns at https://github.com/PyPSA/linopy/issues — your " + "feedback shapes what stabilises. Silence with " + '`warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.', + category=EvolvingAPIWarning, + stacklevel=2, + ) + # Normalize sign (accept "==" or "=" for equality, etc.). The Literal # annotation above covers the user-facing forms; after normalization # ``sign`` holds one of the canonical values in :data:`SIGNS`. @@ -1455,7 +1509,9 @@ def _add_lp( ) seg_mask: DataArray | None = None if bool(full_mask.all()) else full_mask - tangents = tangent_lines(x_expr, x_points, y_points) + # Use the internal impl so we don't fire a second EvolvingAPIWarning — + # ``add_piecewise_formulation`` already warned on entry. + tangents = _tangent_lines_impl(x_expr, x_points, y_points) _add_signed_link( model, y_expr, diff --git a/pyproject.toml b/pyproject.toml index f012ebc6..eb2e05c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,6 +110,18 @@ norecursedirs = ["dev-scripts", "doc", "examples", "benchmark", "benchmarks"] markers = [ "gpu: marks tests as requiring GPU hardware (deselect with '-m \"not gpu\"')", ] +filterwarnings = [ + # Silence our own EvolvingAPIWarning inside the test suite so the + # piecewise tests don't emit 500+ warnings. Users still see them. + # Match by message prefix on the builtin FutureWarning base class + # rather than on ``linopy.EvolvingAPIWarning`` directly — using the + # class reference here would force pytest to import linopy at + # config-parse time, which loads ``linopy.variables`` from + # site-packages and then conflicts with ``--doctest-modules`` + # collection of ``linopy/variables.py`` in the source tree on + # Windows CI. + "ignore:piecewise:FutureWarning", +] [tool.coverage.run] branch = true diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index aef414b0..7224a94a 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -4,6 +4,7 @@ import logging from pathlib import Path +from typing import Literal, TypeAlias import numpy as np import pandas as pd @@ -40,6 +41,9 @@ ) from linopy.solver_capabilities import SolverFeature, get_available_solvers_with_feature +Sign: TypeAlias = Literal["==", "<=", ">="] +Method: TypeAlias = Literal["sos2", "incremental", "lp", "auto"] + _sos2_solvers = get_available_solvers_with_feature( SolverFeature.SOS_CONSTRAINTS, available_solvers ) @@ -622,6 +626,61 @@ def test_sign_le_respected_by_solver(self) -> None: # f(15) = 20 + (30-20)*0.5 = 25 assert m.solution["y"].item() == pytest.approx(25.0, abs=1e-3) + @pytest.mark.skipif(not _sos2_solvers, reason="no SOS2-capable solver available") + @pytest.mark.parametrize( + "x_fix, expected_y", + [ + # Segment 0: (0, 0) → (5, 10), slope 2. At x=2.5, interp y = 5.0. + (2.5, 5.0), + (0.0, 0.0), # segment 0 left edge + (5.0, 10.0), # segment 0 right edge + # Segment 1: (15, 20) → (25, 35), slope 1.5. At x=20, interp y = 27.5. + (20.0, 27.5), + (15.0, 20.0), # segment 1 left edge + (25.0, 35.0), # segment 1 right edge + ], + ) + def test_sign_le_hits_correct_segment( + self, x_fix: float, expected_y: float + ) -> None: + """ + Disjunctive + sign='<=' picks the **right** segment's interpolation. + + With two segments of different slopes, the bound at ``x_fix`` + depends on which segment ``x_fix`` falls in. The solver must + select the binary for that segment and bound ``y`` by *that* + segment's interpolation, not the other. Probes the binary- + select + signed-output-link combination. + """ + m = Model() + x = m.add_variables(lower=0, upper=30, name="x") + y = m.add_variables(lower=0, upper=50, name="y") + m.add_piecewise_formulation( + (y, segments([[0.0, 10.0], [20.0, 35.0]])), # two slopes: 2 and 1.5 + (x, segments([[0.0, 5.0], [15.0, 25.0]])), + sign="<=", + ) + m.add_constraints(x == x_fix) + m.add_objective(-y) + m.solve() + assert m.solution["y"].item() == pytest.approx(expected_y, abs=1e-3) + + @pytest.mark.skipif(not _sos2_solvers, reason="no SOS2-capable solver available") + def test_sign_le_in_forbidden_zone_infeasible(self) -> None: + """X in the gap between segments must be infeasible under sign='<='.""" + m = Model() + x = m.add_variables(lower=0, upper=30, name="x") + y = m.add_variables(lower=0, upper=50, name="y") + m.add_piecewise_formulation( + (y, segments([[0.0, 10.0], [20.0, 35.0]])), + (x, segments([[0.0, 5.0], [15.0, 25.0]])), + sign="<=", + ) + m.add_constraints(x == 10.0) # in the gap (5, 15) + m.add_objective(-y) + status, _ = m.solve() + assert status != "ok" + # =========================================================================== # Validation @@ -1578,7 +1637,8 @@ def test_lp_consistency_with_sos2(self) -> None: y_pts = [0, 20, 30, 35] # concave solutions = {} - for method in ["lp", "sos2", "incremental"]: + methods: list[Method] = ["lp", "sos2", "incremental"] + for method in methods: m = Model() power = m.add_variables(lower=0, upper=30, name="power") fuel = m.add_variables(lower=0, upper=40, name="fuel") @@ -1631,7 +1691,8 @@ def test_lp_per_entity_nan_padding(self) -> None: bp_y = pd.DataFrame([[0, 20, 30, 35], [0, 10, 15, np.nan]], index=["a", "b"]) bp_x = pd.DataFrame([[0, 10, 20, 30], [0, 5, 15, np.nan]], index=["a", "b"]) results: dict[str, float] = {} - for method in ["lp", "sos2"]: + methods: list[Method] = ["lp", "sos2"] + for method in methods: m = Model() coord = pd.Index(["a", "b"], name="entity") x = m.add_variables(lower=0, upper=20, coords=[coord], name="x") @@ -1668,7 +1729,7 @@ def test_lp_rejects_decreasing_x_concave_ge(self) -> None: @pytest.mark.skipif(not _sos2_solvers, reason="no SOS2-capable solver available") @pytest.mark.parametrize("method", ["sos2", "incremental"]) - def test_active_off_with_sign_le_leaves_lower_open(self, method: str) -> None: + def test_active_off_with_sign_le_leaves_lower_open(self, method: Method) -> None: """ Documents the asymmetry between sign='==' and sign='<=' under active=0: equality forces y=0, but '<=' only bounds y ≤ 0 — the @@ -1761,7 +1822,8 @@ def test_lp_accepts_linear_curve(self) -> None: A linear curve is both convex and concave per detection, so LP must accept it with either sign and build the formulation. """ - for sign in ["<=", ">="]: + signs: list[Sign] = ["<=", ">="] + for sign in signs: m = Model() x = m.add_variables(lower=0, upper=30, name="x") y = m.add_variables(lower=0, upper=60, name="y") @@ -1822,7 +1884,8 @@ def test_lp_matches_sos2_on_multi_dim_variables(self) -> None: bp_x = pd.DataFrame([[0, 10, 20, 30], [0, 10, 20, 30]], index=["a", "b"]) bp_y = pd.DataFrame([[0, 20, 30, 35], [0, 15, 25, 30]], index=["a", "b"]) ys: dict[str, xr.DataArray] = {} - for method in ["lp", "sos2"]: + methods: list[Method] = ["lp", "sos2"] + for method in methods: m = Model() x = m.add_variables(lower=0, upper=30, coords=[entities], name="x") y = m.add_variables(lower=0, upper=40, coords=[entities], name="y") @@ -1850,9 +1913,10 @@ def test_lp_consistency_with_sos2_both_directions(self) -> None: """ x_pts = [0, 10, 20, 30] y_pts = [0, 20, 30, 35] # concave + methods: list[Method] = ["lp", "sos2"] for obj_sign in [-1.0, +1.0]: sols: dict[str, float] = {} - for method in ["lp", "sos2"]: + for method in methods: m = Model() p = m.add_variables(lower=0, upper=30, name="p") f = m.add_variables(lower=0, upper=50, name="f") diff --git a/test/test_piecewise_feasibility.py b/test/test_piecewise_feasibility.py new file mode 100644 index 00000000..cedf6d40 --- /dev/null +++ b/test/test_piecewise_feasibility.py @@ -0,0 +1,587 @@ +""" +Strategic feasibility-region equivalence tests for PWL inequality. + +Stress-tests the documented claim that ``add_piecewise_formulation(sign="<=")`` +(or ``">="``) yields the **same feasible region** for ``(x, y)`` regardless +of which method (``lp`` / ``sos2`` / ``incremental``) dispatches the +formulation, on curves where all three are applicable. + +The strong test is :class:`TestRotatedObjective`: for every rotation +``(α, β)``, the support function ``min α·x + β·y`` under the PWL must match +a vertex-enumeration oracle. Equal support functions across enough +directions imply equal (convex) feasible regions. + +:class:`TestDomainBoundary` and :class:`TestPointwiseInfeasibility` add +targeted sanity checks for cases that rotated objectives don't directly +probe (domain-bound enforcement, numerical precision of the curve bound). + +:class:`TestNVariableInequality` covers 3-variable inequality (LP does not +support it — this is SOS2 vs incremental only) and verifies the split: +bounded first tuple, equality on the rest. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, TypeAlias + +import numpy as np +import pytest + +from linopy import Model, available_solvers +from linopy.solver_capabilities import ( + SolverFeature, + get_available_solvers_with_feature, +) +from linopy.variables import Variable + +Sign: TypeAlias = Literal["<=", ">="] +Method: TypeAlias = Literal["lp", "sos2", "incremental"] +MethodND: TypeAlias = Literal["sos2", "incremental"] # LP doesn't support N > 2 + +TOL = 1e-5 +X_LO, X_HI = -100.0, 100.0 +Y_LO, Y_HI = -100.0, 100.0 + +_sos2_solvers = get_available_solvers_with_feature( + SolverFeature.SOS_CONSTRAINTS, available_solvers +) +_any_solvers = [ + s for s in ["highs", "gurobi", "glpk", "cplex"] if s in available_solvers +] + +pytestmark = pytest.mark.skipif( + not (_sos2_solvers and _any_solvers), + reason="need an SOS2-capable LP/MIP solver", +) + + +# --------------------------------------------------------------------------- +# Curve definition + oracle +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Curve: + """A piecewise-linear curve + the sign of the bound it carries.""" + + name: str + x_pts: tuple[float, ...] + y_pts: tuple[float, ...] + sign: Sign + + def f(self, x: float) -> float: + """Linear interpolation of ``y`` at ``x`` (ground truth).""" + return float(np.interp(x, self.x_pts, self.y_pts)) + + def vertices( + self, y_lo: float = Y_LO, y_hi: float = Y_HI + ) -> list[tuple[float, float]]: + """ + Vertices of the feasible polygon — used by the oracle. + + The feasible region for ``sign="<="`` is + ``{(x,y) : x_0 ≤ x ≤ x_n, y_lo ≤ y ≤ f(x)}`` — a polygon whose + vertices are the breakpoints (top edges) plus two bottom corners. + For ``sign=">="`` it is the mirror image clipped to ``y_hi``. + """ + verts = list(zip(self.x_pts, self.y_pts)) + bottom_y = y_lo if self.sign == "<=" else y_hi + verts.append((self.x_pts[0], bottom_y)) + verts.append((self.x_pts[-1], bottom_y)) + return verts + + +CURVES: list[Curve] = [ + Curve("concave-smooth", (0, 1, 2, 3, 4), (0, 1.75, 3, 3.75, 4), "<="), + Curve("concave-shifted", (-2, 0, 5, 10), (-5, 0, 3, 4), "<="), + Curve("convex-steep", (0, 1, 2, 3, 4), (0, 1, 4, 9, 16), ">="), + Curve("linear-lte", (0, 1, 2, 3, 4), (10, 12, 14, 16, 18), "<="), + Curve("linear-gte", (0, 1, 2, 3, 4), (10, 12, 14, 16, 18), ">="), + Curve("two-segment", (0, 10, 20), (0, 15, 20), "<="), +] + + +# --------------------------------------------------------------------------- +# Primitives: build a model, solve, assert infeasibility +# --------------------------------------------------------------------------- + + +def build_model(curve: Curve, method: Method) -> tuple[Model, Variable, Variable]: + """Build a fresh model with bounded x, y linked by the PWL formulation.""" + m = Model() + x = m.add_variables(lower=X_LO, upper=X_HI, name="x") + y = m.add_variables(lower=Y_LO, upper=Y_HI, name="y") + m.add_piecewise_formulation( + (y, list(curve.y_pts)), + (x, list(curve.x_pts)), + sign=curve.sign, + method=method, + ) + return m, x, y + + +def solve_support( + curve: Curve, method: Method, alpha: float, beta: float +) -> tuple[float, float, float]: + """ + Solve ``min α·x + β·y``; return ``(x_sol, y_sol, objective)``. + + The attained *point* is returned alongside the objective because + the point usually reveals the bug (wrong segment, clipped domain, + etc.) more clearly than the objective value alone. + """ + m, x, y = build_model(curve, method) + m.add_objective(alpha * x + beta * y) + status, _ = m.solve() + assert status == "ok", f"{method}/{curve.name}: solve failed at ({alpha}, {beta})" + x_sol = float(m.solution["x"]) + y_sol = float(m.solution["y"]) + return x_sol, y_sol, alpha * x_sol + beta * y_sol + + +def oracle_support(curve: Curve, alpha: float, beta: float) -> float: + """Ground truth ``min α·x + β·y`` over the feasible polygon (vertex min).""" + return min(alpha * vx + beta * vy for vx, vy in curve.vertices()) + + +def assert_infeasible(m: Model, x: Variable, msg: str) -> None: + """Solve with a trivial objective; any non-'ok' status counts as infeasible.""" + m.add_objective(x) # objective is irrelevant — just needs to be set + status, _ = m.solve() + assert status != "ok", msg + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(params=CURVES, ids=lambda c: c.name) +def curve(request: pytest.FixtureRequest) -> Curve: + return request.param + + +@pytest.fixture(params=["lp", "sos2", "incremental"]) +def method(request: pytest.FixtureRequest) -> Method: + return request.param + + +# --------------------------------------------------------------------------- +# Rotated objective — the strong test +# --------------------------------------------------------------------------- + + +_N_DIRECTIONS = 16 +_DIRECTIONS = [ + pytest.param( + float(np.cos(2 * np.pi * i / _N_DIRECTIONS)), + float(np.sin(2 * np.pi * i / _N_DIRECTIONS)), + id=f"{round(360 * i / _N_DIRECTIONS):03d}deg", + ) + for i in range(_N_DIRECTIONS) +] + + +class TestRotatedObjective: + """ + Support-function equivalence: ``min α·x + β·y`` under the PWL + matches the vertex-enumeration oracle for every direction. + + Equal support functions over a dense enough set of directions imply + equal convex feasible regions — the strongest region-identity check. + """ + + @pytest.mark.parametrize("alpha, beta", _DIRECTIONS) + def test_support_matches_oracle( + self, curve: Curve, method: Method, alpha: float, beta: float + ) -> None: + x_sol, y_sol, got = solve_support(curve, method, alpha, beta) + want = oracle_support(curve, alpha, beta) + assert abs(got - want) < TOL, ( + f"\n curve: {curve.name} sign: {curve.sign} method: {method}" + f"\n direction: (α={alpha:+.3f}, β={beta:+.3f})" + f"\n attained point: (x={x_sol:+.6f}, y={y_sol:+.6f})" + f"\n attained obj: {got:+.6f}" + f"\n oracle obj: {want:+.6f}" + f"\n diff: {got - want:+.3e} (TOL={TOL:.1e})" + ) + + +# --------------------------------------------------------------------------- +# Domain boundary — direct probe that x cannot escape [x_min, x_max] +# --------------------------------------------------------------------------- + + +class TestDomainBoundary: + """ + ``x`` outside ``[x_min, x_max]`` is infeasible under all methods. + + LP enforces this with an explicit constraint; SOS2/incremental enforce + it implicitly via ``sum(λ) = 1`` (or the delta ladder). Worth a direct + probe because the two paths are very different implementations. + """ + + def test_below_x_min(self, curve: Curve, method: Method) -> None: + m, x, _ = build_model(curve, method) + m.add_constraints(x == curve.x_pts[0] - 1.0) + assert_infeasible( + m, x, f"{method}/{curve.name}: x < x_min should be infeasible" + ) + + def test_above_x_max(self, curve: Curve, method: Method) -> None: + m, x, _ = build_model(curve, method) + m.add_constraints(x == curve.x_pts[-1] + 1.0) + assert_infeasible( + m, x, f"{method}/{curve.name}: x > x_max should be infeasible" + ) + + +# --------------------------------------------------------------------------- +# Pointwise infeasibility — sanity check that (x, f(x) ± ε) is excluded +# --------------------------------------------------------------------------- + + +class TestPointwiseInfeasibility: + """ + ``y`` pushed past ``f(x)`` in the sign direction is infeasible. + + Rotated objectives probe extremes; this targeted check makes sure the + curve bound is actually a strict inequality at a representative + interior point (catches 'off by one segment' or NaN-mask bugs that + might accidentally allow a small slack). + """ + + def test_just_past_curve(self, curve: Curve, method: Method) -> None: + x_mid = 0.5 * (curve.x_pts[0] + curve.x_pts[-1]) + fx = curve.f(x_mid) + # nudge y past the bound in the forbidden direction + y_bad = fx + 0.01 if curve.sign == "<=" else fx - 0.01 + m, x, y = build_model(curve, method) + m.add_constraints(x == x_mid) + m.add_constraints(y == y_bad) + assert_infeasible( + m, + x, + f"{method}/{curve.name}: (x={x_mid}, y={y_bad}) beyond " + f"f(x)={fx} in direction {curve.sign} should be infeasible", + ) + + +# --------------------------------------------------------------------------- +# 3-variable inequality: sign='<=' splits bounded output from equality inputs +# --------------------------------------------------------------------------- + + +class TestNVariableInequality: + """ + 3-variable ``sign="<="``: the first tuple (output) is bounded above, + the remaining tuples (inputs) are pinned on the curve — equality-linked. + + LP does not support ``N > 2``, so this is SOS2 vs incremental only. + The feasible region is a "ribbon" along the fuel axis parameterised + by the curve's ``(power, heat)`` trajectory: + + { (fuel, power, heat) : ∃ λ SOS2 with Σλ=1, + power = Σλ·p_i, heat = Σλ·h_i, FUEL_LO ≤ fuel ≤ Σλ·f_i } + + Tests probe this region from several angles: a vertex-enumeration + oracle for rotated objectives, plus targeted feasible/infeasible + point checks. + """ + + BP = { + "power": (0, 30, 60, 100), + "fuel": (0, 40, 85, 160), # bounded output (first tuple) + "heat": (0, 25, 55, 95), # input, forced to equality + } + FUEL_LO, FUEL_HI = 0.0, 200.0 + POWER_LO, POWER_HI = 0.0, 100.0 + HEAT_LO, HEAT_HI = 0.0, 100.0 + + @pytest.fixture(params=["sos2", "incremental"]) + def method_3var(self, request: pytest.FixtureRequest) -> MethodND: + return request.param + + # ---- helpers -------------------------------------------------------- + + def _build(self, method: MethodND) -> tuple[Model, Variable, Variable, Variable]: + """CHP model with sign='<=': fuel bounded, power/heat equality-linked.""" + m = Model() + power = m.add_variables(lower=self.POWER_LO, upper=self.POWER_HI, name="power") + fuel = m.add_variables(lower=self.FUEL_LO, upper=self.FUEL_HI, name="fuel") + heat = m.add_variables(lower=self.HEAT_LO, upper=self.HEAT_HI, name="heat") + m.add_piecewise_formulation( + (fuel, list(self.BP["fuel"])), + (power, list(self.BP["power"])), + (heat, list(self.BP["heat"])), + sign="<=", + method=method, + ) + return m, fuel, power, heat + + def _oracle_support_3d( + self, alpha_f: float, alpha_p: float, alpha_h: float + ) -> float: + """ + Ground-truth ``min α_f·fuel + α_p·power + α_h·heat`` over the region. + + The region is a convex polytope with vertices at each breakpoint + in two "layers": the top ``(f_i, p_i, h_i)`` and the bottom + ``(FUEL_LO, p_i, h_i)`` — linear objective extrema are at vertices. + """ + fuels = self.BP["fuel"] + powers = self.BP["power"] + heats = self.BP["heat"] + top = [ + alpha_f * f + alpha_p * p + alpha_h * h + for f, p, h in zip(fuels, powers, heats) + ] + bot = [ + alpha_f * self.FUEL_LO + alpha_p * p + alpha_h * h + for p, h in zip(powers, heats) + ] + return min(top + bot) + + # ---- existing test: fuel pushed against its upper bound ------------- + + @pytest.mark.parametrize("power_fix", [0, 15, 30, 45, 60, 80, 100]) + def test_first_tuple_bounded_rest_equal( + self, method_3var: MethodND, power_fix: float + ) -> None: + m, fuel, power, heat = self._build(method_3var) + m.add_constraints(power == power_fix) + m.add_objective(-fuel) # push fuel against its bound + status, _ = m.solve() + assert status == "ok" + + expect_fuel = float(np.interp(power_fix, self.BP["power"], self.BP["fuel"])) + expect_heat = float(np.interp(power_fix, self.BP["power"], self.BP["heat"])) + + assert abs(float(m.solution["fuel"]) - expect_fuel) < TOL, ( + f"{method_3var}: fuel at power={power_fix} should hit " + f"f(x)={expect_fuel}, got {float(m.solution['fuel'])}" + ) + assert abs(float(m.solution["heat"]) - expect_heat) < TOL, ( + f"{method_3var}: heat at power={power_fix} must equal " + f"f(x)={expect_heat}, got {float(m.solution['heat'])}" + ) + + # ---- new: heat drifting off the curve is infeasible ----------------- + + @pytest.mark.parametrize("power_fix", [15, 45, 80]) + def test_heat_off_curve_is_infeasible( + self, method_3var: MethodND, power_fix: float + ) -> None: + """ + Heat is equality-linked. Pinning heat away from ``f_heat(power)`` + must make the model infeasible under both methods. + """ + expect_heat = float(np.interp(power_fix, self.BP["power"], self.BP["heat"])) + m, fuel, power, heat = self._build(method_3var) + m.add_constraints(power == power_fix) + m.add_constraints(heat == expect_heat + 5.0) # nudge off the curve + m.add_objective(fuel) + status, _ = m.solve() + assert status != "ok", ( + f"{method_3var}: heat={expect_heat + 5} at power={power_fix} " + f"should be infeasible (curve has heat={expect_heat})" + ) + + # ---- new: interior point is feasible -------------------------------- + + @pytest.mark.parametrize("power_fix", [15, 45, 80]) + def test_interior_point_is_feasible( + self, method_3var: MethodND, power_fix: float + ) -> None: + """ + With power/heat on the curve and fuel well below its upper + bound, the point is interior to the ribbon — must be feasible. + """ + expect_heat = float(np.interp(power_fix, self.BP["power"], self.BP["heat"])) + expect_fuel = float(np.interp(power_fix, self.BP["power"], self.BP["fuel"])) + m, fuel, power, heat = self._build(method_3var) + m.add_constraints(power == power_fix) + m.add_constraints(heat == expect_heat) + m.add_constraints(fuel == expect_fuel - 10.0) # below the bound + m.add_objective(fuel) + status, _ = m.solve() + assert status == "ok", ( + f"{method_3var}: interior point (power={power_fix}, " + f"heat={expect_heat}, fuel={expect_fuel - 10}) should be feasible" + ) + + # ---- new: rotated objective in 3D ----------------------------------- + + DIRECTIONS_3D = [ + pytest.param(-1.0, 0.0, 0.0, id="maxfuel"), + pytest.param(+1.0, 0.0, 0.0, id="minfuel"), + pytest.param(0.0, -1.0, 0.0, id="maxpower"), + pytest.param(0.0, +1.0, 0.0, id="minpower"), + pytest.param(0.0, 0.0, -1.0, id="maxheat"), + pytest.param(0.0, 0.0, +1.0, id="minheat"), + pytest.param(-1.0, -1.0, -1.0, id="maxall"), + pytest.param(+1.0, +1.0, +1.0, id="minall"), + ] + + @pytest.mark.parametrize("alpha_f, alpha_p, alpha_h", DIRECTIONS_3D) + def test_rotated_support_matches_oracle( + self, + method_3var: MethodND, + alpha_f: float, + alpha_p: float, + alpha_h: float, + ) -> None: + """ + Support function equivalence in 3-space: each method lands at + the same vertex as the vertex-enumeration oracle. + """ + m, fuel, power, heat = self._build(method_3var) + m.add_objective(alpha_f * fuel + alpha_p * power + alpha_h * heat) + status, _ = m.solve() + assert status == "ok", ( + f"{method_3var}: solve failed at ({alpha_f},{alpha_p},{alpha_h})" + ) + fs = float(m.solution["fuel"]) + ps = float(m.solution["power"]) + hs = float(m.solution["heat"]) + got = alpha_f * fs + alpha_p * ps + alpha_h * hs + want = self._oracle_support_3d(alpha_f, alpha_p, alpha_h) + assert abs(got - want) < TOL, ( + f"\n method: {method_3var}" + f"\n direction: (α_fuel={alpha_f:+}, α_power={alpha_p:+}, α_heat={alpha_h:+})" + f"\n attained: fuel={fs:+.6f}, power={ps:+.6f}, heat={hs:+.6f}" + f"\n attained obj: {got:+.6f} oracle obj: {want:+.6f}" + f"\n diff: {got - want:+.3e} (TOL={TOL:.1e})" + ) + + +# --------------------------------------------------------------------------- +# Hand-computed anchors — sanity-check the oracle itself +# --------------------------------------------------------------------------- + + +class TestHandComputedAnchors: + """ + A handful of pinpoint tests with hand-calculable expected values. + + The parameterised tests compare the solver against a vertex-enumeration + oracle — if that oracle or ``np.interp`` ever drifted, the tests could + continue to pass in false agreement with a broken oracle. These + anchors assert *concrete numbers* a reader can verify with a + calculator in ten seconds, so any oracle drift would surface here. + + Every curve below is arithmetically trivial. Each expected value has + a one-line comment showing the arithmetic. + """ + + # y = 2x on [0, 5] — linear, trivial. + LINEAR = Curve("y_eq_2x", (0, 1, 2, 3, 4, 5), (0, 2, 4, 6, 8, 10), "<=") + + # concave: (0,0) (1,1) (2,1.5) (3,1.75) — slopes 1, 0.5, 0.25 (classic + # diminishing returns) + CONCAVE = Curve("dim_returns", (0, 1, 2, 3), (0, 1, 1.5, 1.75), "<=") + + # convex y = x² sampled at 0..3 — slopes 1, 3, 5 + CONVEX = Curve("y_eq_x2", (0, 1, 2, 3), (0, 1, 4, 9), ">=") + + # ---- 2-variable ---------------------------------------------------- + + @pytest.mark.parametrize("method", ["lp", "sos2", "incremental"]) + def test_linear_at_midsegment(self, method: Method) -> None: + """Y ≤ 2x at x=2.5: max y = 5.0 (halfway between (2, 4) and (3, 6)).""" + m, x, y = build_model(self.LINEAR, method) + m.add_constraints(x == 2.5) + m.add_objective(-y) + m.solve() + assert float(m.solution["y"]) == pytest.approx(5.0, abs=TOL) + + @pytest.mark.parametrize("method", ["lp", "sos2", "incremental"]) + def test_linear_at_breakpoint(self, method: Method) -> None: + """Y ≤ 2x at x=3 (exact breakpoint): max y = 6.0.""" + m, x, y = build_model(self.LINEAR, method) + m.add_constraints(x == 3.0) + m.add_objective(-y) + m.solve() + assert float(m.solution["y"]) == pytest.approx(6.0, abs=TOL) + + @pytest.mark.parametrize("method", ["lp", "sos2", "incremental"]) + def test_linear_at_x_min(self, method: Method) -> None: + """Y ≤ 2x at x=0 (domain lower bound): max y = 0.0.""" + m, x, y = build_model(self.LINEAR, method) + m.add_constraints(x == 0.0) + m.add_objective(-y) + m.solve() + assert float(m.solution["y"]) == pytest.approx(0.0, abs=TOL) + + @pytest.mark.parametrize("method", ["lp", "sos2", "incremental"]) + def test_linear_at_x_max(self, method: Method) -> None: + """Y ≤ 2x at x=5 (domain upper bound): max y = 10.0.""" + m, x, y = build_model(self.LINEAR, method) + m.add_constraints(x == 5.0) + m.add_objective(-y) + m.solve() + assert float(m.solution["y"]) == pytest.approx(10.0, abs=TOL) + + @pytest.mark.parametrize("method", ["lp", "sos2", "incremental"]) + def test_concave_at_midsegment(self, method: Method) -> None: + """Y ≤ f(x) concave at x=1.5: max y = (1 + 1.5)/2 = 1.25.""" + m, x, y = build_model(self.CONCAVE, method) + m.add_constraints(x == 1.5) + m.add_objective(-y) + m.solve() + assert float(m.solution["y"]) == pytest.approx(1.25, abs=TOL) + + @pytest.mark.parametrize("method", ["lp", "sos2", "incremental"]) + def test_convex_ge_at_midsegment(self, method: Method) -> None: + """Y ≥ f(x) convex at x=1.5: min y = (1 + 4)/2 = 2.5.""" + m, x, y = build_model(self.CONVEX, method) + m.add_constraints(x == 1.5) + m.add_objective(y) # minimise — pushes y against the lower bound (curve) + m.solve() + assert float(m.solution["y"]) == pytest.approx(2.5, abs=TOL) + + # ---- 3-variable CHP ------------------------------------------------ + + @pytest.mark.parametrize("method_3var", ["sos2", "incremental"]) + def test_chp_at_breakpoint(self, method_3var: MethodND) -> None: + """CHP at power=60 (exact breakpoint 2): max fuel=85, heat=55.""" + m = Model() + power = m.add_variables(lower=0, upper=100, name="power") + fuel = m.add_variables(lower=0, upper=200, name="fuel") + heat = m.add_variables(lower=0, upper=100, name="heat") + m.add_piecewise_formulation( + (fuel, [0, 40, 85, 160]), + (power, [0, 30, 60, 100]), + (heat, [0, 25, 55, 95]), + sign="<=", + method=method_3var, + ) + m.add_constraints(power == 60.0) + m.add_objective(-fuel) + m.solve() + assert float(m.solution["fuel"]) == pytest.approx(85.0, abs=TOL) + assert float(m.solution["heat"]) == pytest.approx(55.0, abs=TOL) + + @pytest.mark.parametrize("method_3var", ["sos2", "incremental"]) + def test_chp_at_midsegment(self, method_3var: MethodND) -> None: + """ + CHP at power=45 (midway between bp1=30 and bp2=60): + fuel = (40 + 85)/2 = 62.5, heat = (25 + 55)/2 = 40.0. + """ + m = Model() + power = m.add_variables(lower=0, upper=100, name="power") + fuel = m.add_variables(lower=0, upper=200, name="fuel") + heat = m.add_variables(lower=0, upper=100, name="heat") + m.add_piecewise_formulation( + (fuel, [0, 40, 85, 160]), + (power, [0, 30, 60, 100]), + (heat, [0, 25, 55, 95]), + sign="<=", + method=method_3var, + ) + m.add_constraints(power == 45.0) + m.add_objective(-fuel) + m.solve() + assert float(m.solution["fuel"]) == pytest.approx(62.5, abs=TOL) + assert float(m.solution["heat"]) == pytest.approx(40.0, abs=TOL) From c1520404dc6ae9486e1b3912921602c92ec355e4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:46:09 +0200 Subject: [PATCH 35/65] =?UTF-8?q?docs:=20N=E2=89=A53=20sign=20semantics=20?= =?UTF-8?q?=E2=80=94=20"N=E2=88=921=20jointly=20pinned,=201=20bounded"=20f?= =?UTF-8?q?raming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous framing ("first bounded, rest forced to equality") was correct but left two things unclear: 1. What "rest forced to equality" means when there are multiple equality-side tuples — they are jointly constrained to a single segment position on the curve. Pinning power AND heat to independent values is infeasible; their values are coupled by the shared segment parameter. 2. Which variable should occupy the first (bounded) position. A consumption-side variable such as fuel intake yields a valid but *loose* formulation — the characteristic curve fixes fuel draw at a given load, so sign="<=" on fuel admits operating points the plant cannot physically realise. Safe only when no objective rewards driving it below the curve; otherwise the optimum can be non-physical. The canonical choice is a dissipation path: heat rejection (also called thermal curtailment), electrical curtailment, or emissions after post-treatment. The reference page also now notes that inequality can be faster than equality — 2-variable cases with matching curvature dispatch to pure LP, and the relaxed feasible region typically tightens the LP relaxation for N≥3 too. Choice of sign is a speed-vs-tightness trade-off in addition to a physics one. Updates: - doc/piecewise-linear-constraints.rst: reframe the sign section as "N−1 jointly-pinned, 1 bounded", with an explicit 3-variable example showing independent pinning of equality-side tuples is infeasible. New "Choice of bounded tuple" paragraph opens with heat rejection and closes with the speed-vs-tightness trade-off. - examples/piecewise-linear-constraints.ipynb Section 4: the 3-var CHP example now bounds ``heat`` (heat rejection) with ``power`` and ``fuel`` pinned. Prose introduces "heat rejection" / "thermal curtailment" and notes the speed benefit. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/piecewise-linear-constraints.rst | 59 ++++++++++++++--- examples/piecewise-linear-constraints.ipynb | 73 +++++++++++++++++---- 2 files changed, 111 insertions(+), 21 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index d2c150fd..80c7f696 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -91,15 +91,56 @@ The ``sign`` parameter — equality vs inequality The ``sign`` argument of ``add_piecewise_formulation`` chooses whether all expressions are locked onto the curve or whether the first one is bounded: -- ``sign="=="`` (default): every expression lies *exactly* on the piecewise - curve — joint equality. All tuples are symmetric. -- ``sign="<="``: the **first** tuple's expression is bounded **above** by its - interpolated value; the remaining tuples are forced to equality (inputs on - the curve). Reads as *"first expression ≤ f(the rest)"*. -- ``sign=">="``: same but the first is bounded **below**. - -This is the *first-tuple convention* — a single inequality direction applies to -one designated output; the other tuples parameterise the curve. +- ``sign="=="`` (default): **every** expression lies *exactly* on the + piecewise curve — joint equality. All tuples are symmetric. The feasible + region is a 1-D curve in N-space. +- ``sign="<="``: **N−1 tuples are pinned** to the curve (moving together along + it), and the **first** tuple's expression is **bounded above** by its + interpolated value at that shared curve position — it can undershoot. +- ``sign=">="``: same split, but the first is bounded **below** (overshoots + admitted). + +Inequality relaxes **one** tuple's curve-equality into a one-sided bound. The +others keep moving together along the curve in lockstep — this is the +*first-tuple convention*. + +**What this means geometrically.** + +For 2 variables (``(y, yp), (x, xp)``, ``sign="<="``), "N−1 pinned, 1 bounded" +reduces to the familiar **hypograph**: ``x`` moves along the breakpoint axis, +``y`` ranges from its lower bound up to ``f(x)``. + +For 3+ variables, the N−1 pinned tuples are **jointly constrained** to a +single segment position on the piecewise curve. In a CHP characteristic +``(power, fuel, heat)`` with ``sign="<="``, ``fuel`` and ``heat`` trace the +curve simultaneously: specifying ``fuel = 85`` determines the segment +position, which in turn fixes ``heat`` to its curve value at that position. +Assigning ``heat`` to any value inconsistent with the same segment renders +the system infeasible. + +The feasible region in N-space is a 2-dimensional manifold: the 1-D +parametric curve at its upper boundary (for ``"<="``), extended along the +first tuple's axis down to that variable's lower bound. + +**Choice of bounded tuple.** The first tuple should correspond to a +quantity with a mechanism for below-curve operation — typically a +controllable dissipation path: heat rejection via cooling tower (also +called *thermal curtailment*), electrical curtailment, or emissions after +post-treatment. Placing a consumption-side variable such as fuel intake +in the bounded position yields a valid but **loose** formulation: the +characteristic curve fixes fuel draw at a given load, so ``sign="<="`` on +fuel admits operating points the plant cannot physically realise. An +objective that rewards lower fuel may then find a non-physical optimum +— safe only when no such objective pressure exists. + +Relatedly, inequality formulations can also be **faster to solve**: with +2 variables and matching curvature, ``method="auto"`` dispatches to the +pure-LP chord formulation (no SOS2, no binaries). For N≥3 the solver +still reaches for SOS2/incremental, but the relaxed feasible region +often tightens the LP relaxation and reduces branch-and-bound work. +Choose ``sign="=="`` when you want strict curve adherence (the +tightest feasible region) and ``sign="<="`` / ``">="`` when either the +physics admits dissipation or the speedup is worth the relaxation. **When is a one-sided bound wanted?** diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 2285c3f6..d76e19ba 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -6,7 +6,7 @@ "source": [ "# Piecewise Linear Constraints Tutorial\n", "\n", - "`add_piecewise_formulation` links variables through shared breakpoint weights. Every section below stacks one feature on top of a small shared dispatch pattern — if you want the math, see the [reference page](piecewise-linear-constraints). For inequality bounds and the LP chord formulation in depth, see the [inequality bounds notebook](piecewise-inequality-bounds-tutorial).\n", + "`add_piecewise_formulation` links variables through shared breakpoint weights. Every section below stacks one feature on top of a small shared dispatch pattern \u2014 if you want the math, see the [reference page](piecewise-linear-constraints). For inequality bounds and the LP chord formulation in depth, see the [inequality bounds notebook](piecewise-inequality-bounds-tutorial).\n", "\n", "The baseline we extend:\n", "\n", @@ -105,13 +105,13 @@ "source": [ "## 2. Picking a method\n", "\n", - "`method=\"auto\"` (default) picks the cheapest correct formulation based on sign, curvature and breakpoint layout. The explicit options — `\"sos2\"`, `\"incremental\"`, `\"lp\"` — give the same optimum on equality cases where they all apply, so the choice is about **cost** (auxiliary variables, solver capability), not correctness.\n", + "`method=\"auto\"` (default) picks the cheapest correct formulation based on sign, curvature and breakpoint layout. The explicit options \u2014 `\"sos2\"`, `\"incremental\"`, `\"lp\"` \u2014 give the same optimum on equality cases where they all apply, so the choice is about **cost** (auxiliary variables, solver capability), not correctness.\n", "\n", "| method | needs | creates |\n", "|---|---|---|\n", "| `sos2` | SOS2-capable solver | lambdas (continuous) |\n", "| `incremental` | MIP solver, strictly monotonic breakpoints | deltas (continuous) + binaries |\n", - "| `lp` | any LP solver | no variables — requires `sign != \"==\"`, 2 tuples, matching curvature |\n", + "| `lp` | any LP solver | no variables \u2014 requires `sign != \"==\"`, 2 tuples, matching curvature |\n", "\n", "Below: all applicable methods yield the same fuel dispatch on this convex curve." ] @@ -145,7 +145,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 3. Disjunctive segments — gaps in the operating range\n", + "## 3. Disjunctive segments \u2014 gaps in the operating range\n", "\n", "When operating regions are **disconnected** (a diesel generator that is either off or running in [50, 80] MW, never in between), use `segments()` instead of `breakpoints()`. A binary picks which segment is active; inside it SOS2 interpolates as usual." ] @@ -187,11 +187,16 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 4. Inequality bounds — `sign=\"<=\"`\n", + "## 4. Inequality bounds \u2014 `sign=\"<=\"`\n", "\n", - "If you don't need `fuel = f(power)` exactly but just `fuel ≤ f(power)`, pass `sign=\"<=\"`. On a **concave** curve with `<=` (or a **convex** curve with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation — no binaries, no SOS2. The first tuple is the bounded output; the rest are inputs forced onto the curve.\n", + "`sign=\"<=\"` / `\">=\"` relaxes **one** tuple into a one-sided bound; the remaining N\u22121 tuples stay pinned to the curve and move together along it in lockstep.\n", "\n", - "See the [inequality bounds notebook](piecewise-inequality-bounds-tutorial) for mismatched curvature, multi-variable cases and what the auto-dispatch falls back to." + "- With 2 tuples, this is the familiar hypograph `{(x, y) : y \u2264 f(x)}`.\n", + "- With 3+ tuples, the N\u22121 \"pinned\" inputs cannot be constrained independently \u2014 they share a single curve-segment position.\n", + "\n", + "On a concave curve with `<=` (or convex with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation \u2014 no binaries, no SOS2. The first tuple is the bounded output.\n", + "\n", + "See the [inequality bounds notebook](piecewise-inequality-bounds-tutorial) for mismatched curvature, auto-dispatch fallbacks, and more geometry." ] }, { @@ -227,7 +232,51 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 5. Unit commitment — `active`\n", + "**3-variable case \u2014 CHP plant with heat rejection**\n", + "\n", + "A CHP plant is characterised by a 1-parameter family of operating points (the load parameter). Power, fuel and heat are joint outputs of that parameter, tracing the characteristic curve simultaneously.\n", + "\n", + "For the inequality formulation to be physically meaningful, the first (bounded) tuple must correspond to a quantity with an available dissipation mechanism. A canonical example is **heat rejection** (also called *thermal curtailment*): when downstream heating demand falls below the plant's co-generation capacity at its committed electrical output, excess thermal output is rejected via a cooling tower. Electrical output and fuel draw remain pinned to the load parameter; heat delivery can be anywhere from the rejection floor up to the characteristic curve.\n", + "\n", + "Other admissible choices for the bounded tuple: electrical curtailment, emissions after post-treatment. Placing a consumption-side variable (such as fuel intake) in the bounded position yields a valid but *loose* formulation \u2014 safe only when no objective rewards driving it below the curve.\n", + "\n", + "Inequality formulations can also be faster to solve than the equality variant (see *Choice of bounded tuple* in the reference page), so the speed-vs-tightness trade-off is worth weighing even when the physics is strictly equality.\n", + "\n", + "Below: `heat` is the bounded output (rejection); `power` and `fuel` are pinned to the characteristic curve." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "m = linopy.Model()\n", + "power = m.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", + "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", + "heat = m.add_variables(name=\"heat\", lower=0, coords=[time])\n", + "\n", + "# bounded output listed FIRST (heat rejection); power, fuel pinned to the curve\n", + "m.add_piecewise_formulation(\n", + " (heat, [0, 25, 55, 95]), # bounded above \u2014 heat rejection\n", + " (power, [0, 30, 60, 100]), # pinned \u2014 electrical output at load\n", + " (fuel, [0, 40, 85, 160]), # pinned \u2014 fuel draw at load\n", + " sign=\"<=\",\n", + " method=\"sos2\",\n", + ")\n", + "# fix the load via a power target \u2014 remaining outputs are determined\n", + "m.add_constraints(power == xr.DataArray([30, 60, 100], coords=[time]))\n", + "m.add_objective(-heat.sum()) # maximise heat \u2014 no rejection required\n", + "m.solve(reformulate_sos=\"auto\")\n", + "\n", + "m.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Unit commitment \u2014 `active`\n", "\n", "A binary variable gates the whole formulation. `active=0` forces the PWL variables (and thus all linked outputs) to zero. Combined with the natural `lower=0` on cost/fuel/heat, this gives a clean on/off coupling:\n", "\n", @@ -259,7 +308,7 @@ " (fuel, [40, 90, 170]),\n", " active=commit,\n", ")\n", - "# demand below p_min at t=1 — commit must be 0 and backup covers it\n", + "# demand below p_min at t=1 \u2014 commit must be 0 and backup covers it\n", "m.add_constraints(power + backup == xr.DataArray([15, 80, 40], coords=[time]))\n", "m.add_objective(fuel.sum() + 50 * commit.sum() + 200 * backup.sum())\n", "m.solve(reformulate_sos=\"auto\")\n", @@ -270,9 +319,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 6. N-variable linking — CHP plant\n", + "## 6. N-variable linking \u2014 CHP plant\n", "\n", - "More than two variables can share the same interpolation — useful for combined heat-and-power plants where power, fuel and heat are all functions of a single operating point." + "More than two variables can share the same interpolation \u2014 useful for combined heat-and-power plants where power, fuel and heat are all functions of a single operating point." ] }, { @@ -306,7 +355,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 7. Per-entity breakpoints — a fleet of generators\n", + "## 7. Per-entity breakpoints \u2014 a fleet of generators\n", "\n", "Pass a dict to `breakpoints()` with entity names as keys for different curves per entity. Ragged lengths are NaN-padded automatically, and breakpoints broadcast over any remaining dimensions (here, `time`)." ] From a60d685e3ab3f4ee5ee16b4243b9026f5c2da787 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:46:23 +0000 Subject: [PATCH 36/65] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- examples/piecewise-linear-constraints.ipynb | 42 ++++++++++----------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index d76e19ba..a8035e50 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -6,7 +6,7 @@ "source": [ "# Piecewise Linear Constraints Tutorial\n", "\n", - "`add_piecewise_formulation` links variables through shared breakpoint weights. Every section below stacks one feature on top of a small shared dispatch pattern \u2014 if you want the math, see the [reference page](piecewise-linear-constraints). For inequality bounds and the LP chord formulation in depth, see the [inequality bounds notebook](piecewise-inequality-bounds-tutorial).\n", + "`add_piecewise_formulation` links variables through shared breakpoint weights. Every section below stacks one feature on top of a small shared dispatch pattern — if you want the math, see the [reference page](piecewise-linear-constraints). For inequality bounds and the LP chord formulation in depth, see the [inequality bounds notebook](piecewise-inequality-bounds-tutorial).\n", "\n", "The baseline we extend:\n", "\n", @@ -105,13 +105,13 @@ "source": [ "## 2. Picking a method\n", "\n", - "`method=\"auto\"` (default) picks the cheapest correct formulation based on sign, curvature and breakpoint layout. The explicit options \u2014 `\"sos2\"`, `\"incremental\"`, `\"lp\"` \u2014 give the same optimum on equality cases where they all apply, so the choice is about **cost** (auxiliary variables, solver capability), not correctness.\n", + "`method=\"auto\"` (default) picks the cheapest correct formulation based on sign, curvature and breakpoint layout. The explicit options — `\"sos2\"`, `\"incremental\"`, `\"lp\"` — give the same optimum on equality cases where they all apply, so the choice is about **cost** (auxiliary variables, solver capability), not correctness.\n", "\n", "| method | needs | creates |\n", "|---|---|---|\n", "| `sos2` | SOS2-capable solver | lambdas (continuous) |\n", "| `incremental` | MIP solver, strictly monotonic breakpoints | deltas (continuous) + binaries |\n", - "| `lp` | any LP solver | no variables \u2014 requires `sign != \"==\"`, 2 tuples, matching curvature |\n", + "| `lp` | any LP solver | no variables — requires `sign != \"==\"`, 2 tuples, matching curvature |\n", "\n", "Below: all applicable methods yield the same fuel dispatch on this convex curve." ] @@ -145,7 +145,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 3. Disjunctive segments \u2014 gaps in the operating range\n", + "## 3. Disjunctive segments — gaps in the operating range\n", "\n", "When operating regions are **disconnected** (a diesel generator that is either off or running in [50, 80] MW, never in between), use `segments()` instead of `breakpoints()`. A binary picks which segment is active; inside it SOS2 interpolates as usual." ] @@ -187,14 +187,14 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 4. Inequality bounds \u2014 `sign=\"<=\"`\n", + "## 4. Inequality bounds — `sign=\"<=\"`\n", "\n", - "`sign=\"<=\"` / `\">=\"` relaxes **one** tuple into a one-sided bound; the remaining N\u22121 tuples stay pinned to the curve and move together along it in lockstep.\n", + "`sign=\"<=\"` / `\">=\"` relaxes **one** tuple into a one-sided bound; the remaining N−1 tuples stay pinned to the curve and move together along it in lockstep.\n", "\n", - "- With 2 tuples, this is the familiar hypograph `{(x, y) : y \u2264 f(x)}`.\n", - "- With 3+ tuples, the N\u22121 \"pinned\" inputs cannot be constrained independently \u2014 they share a single curve-segment position.\n", + "- With 2 tuples, this is the familiar hypograph `{(x, y) : y ≤ f(x)}`.\n", + "- With 3+ tuples, the N−1 \"pinned\" inputs cannot be constrained independently — they share a single curve-segment position.\n", "\n", - "On a concave curve with `<=` (or convex with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation \u2014 no binaries, no SOS2. The first tuple is the bounded output.\n", + "On a concave curve with `<=` (or convex with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation — no binaries, no SOS2. The first tuple is the bounded output.\n", "\n", "See the [inequality bounds notebook](piecewise-inequality-bounds-tutorial) for mismatched curvature, auto-dispatch fallbacks, and more geometry." ] @@ -232,13 +232,13 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "**3-variable case \u2014 CHP plant with heat rejection**\n", + "**3-variable case — CHP plant with heat rejection**\n", "\n", "A CHP plant is characterised by a 1-parameter family of operating points (the load parameter). Power, fuel and heat are joint outputs of that parameter, tracing the characteristic curve simultaneously.\n", "\n", "For the inequality formulation to be physically meaningful, the first (bounded) tuple must correspond to a quantity with an available dissipation mechanism. A canonical example is **heat rejection** (also called *thermal curtailment*): when downstream heating demand falls below the plant's co-generation capacity at its committed electrical output, excess thermal output is rejected via a cooling tower. Electrical output and fuel draw remain pinned to the load parameter; heat delivery can be anywhere from the rejection floor up to the characteristic curve.\n", "\n", - "Other admissible choices for the bounded tuple: electrical curtailment, emissions after post-treatment. Placing a consumption-side variable (such as fuel intake) in the bounded position yields a valid but *loose* formulation \u2014 safe only when no objective rewards driving it below the curve.\n", + "Other admissible choices for the bounded tuple: electrical curtailment, emissions after post-treatment. Placing a consumption-side variable (such as fuel intake) in the bounded position yields a valid but *loose* formulation — safe only when no objective rewards driving it below the curve.\n", "\n", "Inequality formulations can also be faster to solve than the equality variant (see *Choice of bounded tuple* in the reference page), so the speed-vs-tightness trade-off is worth weighing even when the physics is strictly equality.\n", "\n", @@ -258,15 +258,15 @@ "\n", "# bounded output listed FIRST (heat rejection); power, fuel pinned to the curve\n", "m.add_piecewise_formulation(\n", - " (heat, [0, 25, 55, 95]), # bounded above \u2014 heat rejection\n", - " (power, [0, 30, 60, 100]), # pinned \u2014 electrical output at load\n", - " (fuel, [0, 40, 85, 160]), # pinned \u2014 fuel draw at load\n", + " (heat, [0, 25, 55, 95]), # bounded above — heat rejection\n", + " (power, [0, 30, 60, 100]), # pinned — electrical output at load\n", + " (fuel, [0, 40, 85, 160]), # pinned — fuel draw at load\n", " sign=\"<=\",\n", " method=\"sos2\",\n", ")\n", - "# fix the load via a power target \u2014 remaining outputs are determined\n", + "# fix the load via a power target — remaining outputs are determined\n", "m.add_constraints(power == xr.DataArray([30, 60, 100], coords=[time]))\n", - "m.add_objective(-heat.sum()) # maximise heat \u2014 no rejection required\n", + "m.add_objective(-heat.sum()) # maximise heat — no rejection required\n", "m.solve(reformulate_sos=\"auto\")\n", "\n", "m.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas()" @@ -276,7 +276,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 5. Unit commitment \u2014 `active`\n", + "## 5. Unit commitment — `active`\n", "\n", "A binary variable gates the whole formulation. `active=0` forces the PWL variables (and thus all linked outputs) to zero. Combined with the natural `lower=0` on cost/fuel/heat, this gives a clean on/off coupling:\n", "\n", @@ -308,7 +308,7 @@ " (fuel, [40, 90, 170]),\n", " active=commit,\n", ")\n", - "# demand below p_min at t=1 \u2014 commit must be 0 and backup covers it\n", + "# demand below p_min at t=1 — commit must be 0 and backup covers it\n", "m.add_constraints(power + backup == xr.DataArray([15, 80, 40], coords=[time]))\n", "m.add_objective(fuel.sum() + 50 * commit.sum() + 200 * backup.sum())\n", "m.solve(reformulate_sos=\"auto\")\n", @@ -319,9 +319,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 6. N-variable linking \u2014 CHP plant\n", + "## 6. N-variable linking — CHP plant\n", "\n", - "More than two variables can share the same interpolation \u2014 useful for combined heat-and-power plants where power, fuel and heat are all functions of a single operating point." + "More than two variables can share the same interpolation — useful for combined heat-and-power plants where power, fuel and heat are all functions of a single operating point." ] }, { @@ -355,7 +355,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 7. Per-entity breakpoints \u2014 a fleet of generators\n", + "## 7. Per-entity breakpoints — a fleet of generators\n", "\n", "Pass a dict to `breakpoints()` with entity names as keys for different curves per entity. Ragged lengths are NaN-padded automatically, and breakpoints broadcast over any remaining dimensions (here, `time`)." ] From 3fadd08a9f2d06404b48c829ee6c34fe3b7b1abf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:13:18 +0200 Subject: [PATCH 37/65] docs(piecewise): add at-a-glance method comparison table Adds a comparison table to doc/piecewise-linear-constraints.rst summarising sos2/incremental/lp/disjunctive on segment layout, supported signs, tuple count, curvature, auxiliaries, active=, and solver requirements. Also exposes PiecewiseFormulation and slopes_to_points in doc/api.rst. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/api.rst | 2 ++ doc/piecewise-linear-constraints.rst | 52 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/doc/api.rst b/doc/api.rst index 434a77b8..07eebfeb 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -19,8 +19,10 @@ Creating a model model.Model.add_constraints model.Model.add_objective model.Model.add_piecewise_formulation + piecewise.PiecewiseFormulation piecewise.breakpoints piecewise.segments + piecewise.slopes_to_points piecewise.tangent_lines model.Model.linexpr model.Model.remove_constraints diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 80c7f696..31aca446 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -312,6 +312,58 @@ The resolved choice is exposed on the returned ``PiecewiseFormulation`` via ``.method`` (and ``.convexity`` when well-defined). An ``INFO``-level log line explains the resolution whenever ``method="auto"`` is in play. +At-a-glance comparison: + +.. list-table:: + :header-rows: 1 + :widths: 26 18 18 18 20 + + * - Property + - ``sos2`` + - ``incremental`` + - ``lp`` + - Disjunctive + * - Segment layout + - Connected + - Connected + - Connected + - Disconnected + * - Supported ``sign`` + - ``==``, ``<=``, ``>=`` + - ``==``, ``<=``, ``>=`` + - ``<=``, ``>=`` only + - ``==``, ``<=``, ``>=`` + * - Number of tuples + - Any (≥ 2) + - Any (≥ 2) + - Exactly 2 + - Any (≥ 2) + * - Breakpoint order + - Any + - Strictly monotonic + - Strictly monotonic + - Any (per segment) + * - Curvature requirement + - None + - None + - Concave (``<=``) or convex (``>=``) + - None + * - Auxiliary variables + - Continuous + SOS2 + - Continuous + binary + - **None** + - Binary + SOS2 + * - ``active=`` supported + - Yes + - Yes + - No + - Yes + * - Solver requirement + - SOS2-capable + - MIP-capable + - **Any LP solver** + - SOS2 + MIP + SOS2 (Convex Combination) ~~~~~~~~~~~~~~~~~~~~~~~~~~ From a3e95f6997265da339ddb094cb940c2c4005d3b1 Mon Sep 17 00:00:00 2001 From: Felix <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:53:23 +0200 Subject: [PATCH 38/65] refactor(piecewise): per-tuple sign + categorized internal flow (#664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(piecewise): per-tuple sign + categorized internal flow Public API - Drop the formulation-level `sign=` keyword on `add_piecewise_formulation`. Pass the sign per-tuple as an optional 3rd element instead: `(y, y_pts, "<=")` instead of `sign="<="`. - Tuples without a sign default to "=="; the bounded tuple need not be first. - Validate: at most one tuple may carry a non-equality sign; with 3 or more tuples all signs must be "==" (the multi-input bounded case is reserved for a future bivariate / triangulated piecewise API). - Old `sign=` callers get a clear `TypeError` pointing to the new shape. Internal flow - Introduce `_PwlInputs` to carry the categorized inputs (`bounded_*` vs `pinned_*`) through the dispatch chain. `_build_links`, `_try_lp`, `_lp_eligibility`, `_add_continuous`, `_add_disjunctive` all consume it directly — no more positional "first tuple is special" convention. - User's tuple order is preserved end-to-end. Tests - Migrate ~30 callers to per-tuple sign. - Drop tests of the now-rejected N>=3 + non-equality case (`TestNVariableInequality`, the two CHP `TestHandComputedAnchors` cases, `test_nvar_inequality_bounds_first_tuple`). - Add tests for: removed-`sign=`-keyword migration error, multiple bounded tuples rejected, N>=3 + non-equality rejected, bounded tuple in the second position still routes to LP. Docs - Rewrite the "sign parameter" section of doc/piecewise-linear-constraints.rst for per-tuple sign. Update the comparison table, examples, and the release notes entry. Co-Authored-By: Claude Opus 4.7 (1M context) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * docs(piecewise): leverage per-tuple notation, rephrase restrictions as invitations Drops vestigial framing from the old API throughout the user docs and example notebooks. The "first-tuple convention" and "N−1 jointly pinned" scaffolding existed only to explain why position 0 was special — with per-tuple sign that explanation isn't needed. Each tuple's role is now visible at the call site. Restrictions (one bounded tuple max; 3+ must be all-equality) are reframed as invitations: "open an issue at https://github.com/PyPSA/linopy/issues if you have a use case." We don't actually know what shape future support takes — better to invite scoping than to commit to a specific "future bivariate / triangulated piecewise API" we haven't designed. - doc/piecewise-linear-constraints.rst: rewrite the restrictions block, the N-variable linking section, and the SOS2 generated-names list to use the new framing. Update See Also link target. - examples/piecewise-inequality-bounds.ipynb: rewrite intro, math, code, and summary cells. Drop the four cells (10–13) that were dedicated to the now-rejected 3-variable inequality case (the 3D ribbon plot and its "first-tuple convention" justification). Notebook executes end-to-end on Gurobi. - examples/piecewise-linear-constraints.ipynb: drop the 3-variable CHP inequality demo (cells 12–13); the joint-equality CHP case is already in section 6. Update the inequality intro for per-tuple sign. - linopy/piecewise.py: rephrase docstring restrictions and the entry-point ValueError to invite an issue rather than promise a specific future API. Co-Authored-By: Claude Opus 4.7 (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.7 (1M context) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- doc/piecewise-linear-constraints.rst | 249 +++++----- doc/release_notes.rst | 6 +- examples/piecewise-inequality-bounds.ipynb | 381 +-------------- examples/piecewise-linear-constraints.ipynb | 76 +-- linopy/piecewise.py | 492 +++++++++++--------- test/test_piecewise_constraints.py | 143 +++--- test/test_piecewise_feasibility.py | 237 +--------- 7 files changed, 479 insertions(+), 1105 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 31aca446..17bb7b95 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -39,14 +39,15 @@ Quick Start # (pure LP with chord constraints when the curve's curvature matches # the requested sign; SOS2/incremental otherwise). m.add_piecewise_formulation( - (fuel, [0, 20, 30, 35]), # bounded output listed FIRST - (power, [0, 10, 20, 30]), # input always on the curve - sign="<=", + (fuel, [0, 20, 30, 35], "<="), # bounded by the curve + (power, [0, 10, 20, 30]), # pinned to the curve ) -Each ``(expression, breakpoints)`` tuple pairs a variable with its breakpoint -values. All tuples share interpolation weights, so at any feasible point every -variable corresponds to the *same* point on the piecewise curve. +Each ``(expression, breakpoints[, sign])`` tuple pairs a variable with its +breakpoint values, and optionally marks it as bounded by the curve (``"<="`` +or ``">="``) instead of pinned to it. All tuples share interpolation weights, +so at any feasible point every variable corresponds to the *same* point on +the piecewise curve. API @@ -58,18 +59,17 @@ API .. code-block:: python m.add_piecewise_formulation( - (expr1, breakpoints1), - (expr2, breakpoints2), + (expr1, breakpoints1), # pinned (sign defaults to "==") + (expr2, breakpoints2, "<="), # or with an explicit sign ..., - sign="==", # "==", "<=", or ">=" method="auto", # "auto", "sos2", "incremental", or "lp" active=None, # binary variable to gate the constraint name=None, # base name for generated variables/constraints ) -Creates auxiliary variables and constraints that enforce either an equality -(``sign="=="``, default) or a one-sided bound (``sign="<="`` / ``">="``) of the -first expression by the piecewise function of the rest. +Creates auxiliary variables and constraints that enforce either a joint +equality (all tuples on the curve, the default) or a one-sided bound +(at most one tuple bounded by the curve, the rest pinned). ``breakpoints`` and ``segments`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -85,110 +85,94 @@ Factory functions that create DataArrays with the correct dimension names: linopy.segments({"gen1": [(0, 10)], "gen2": [(0, 80)]}, dim="gen") -The ``sign`` parameter — equality vs inequality ------------------------------------------------- +Per-tuple sign — equality vs inequality +---------------------------------------- -The ``sign`` argument of ``add_piecewise_formulation`` chooses whether all -expressions are locked onto the curve or whether the first one is bounded: +By default each tuple's expression is **pinned** to the piecewise curve. +Pass a third tuple element (``"<="`` or ``">="``) to mark a single +expression as **bounded** by the curve — it can undershoot (``"<="``) or +overshoot (``">="``) the interpolated value, while every other tuple +stays pinned. -- ``sign="=="`` (default): **every** expression lies *exactly* on the - piecewise curve — joint equality. All tuples are symmetric. The feasible - region is a 1-D curve in N-space. -- ``sign="<="``: **N−1 tuples are pinned** to the curve (moving together along - it), and the **first** tuple's expression is **bounded above** by its - interpolated value at that shared curve position — it can undershoot. -- ``sign=">="``: same split, but the first is bounded **below** (overshoots - admitted). +.. code-block:: python + + # Joint equality (default): both expressions on the curve. + m.add_piecewise_formulation((y, y_pts), (x, x_pts)) + + # Bounded above: y <= f(x), x pinned. + m.add_piecewise_formulation((y, y_pts, "<="), (x, x_pts)) + + # Bounded below: y >= f(x), x pinned. + m.add_piecewise_formulation((y, y_pts, ">="), (x, x_pts)) + + # 3-variable equality (CHP heat/power/fuel): all three on one curve. + m.add_piecewise_formulation((power, p_pts), (fuel, f_pts), (heat, h_pts)) -Inequality relaxes **one** tuple's curve-equality into a one-sided bound. The -others keep moving together along the curve in lockstep — this is the -*first-tuple convention*. +**Restrictions (current):** -**What this means geometrically.** +- At most one tuple may carry a non-equality sign — a single bounded side. +- With **3 or more** tuples, all signs must be ``"=="``. -For 2 variables (``(y, yp), (x, xp)``, ``sign="<="``), "N−1 pinned, 1 bounded" -reduces to the familiar **hypograph**: ``x`` moves along the breakpoint axis, -``y`` ranges from its lower bound up to ``f(x)``. +Multi-bounded and N≥3-inequality use cases aren't supported yet. If you +have a concrete use case, please open an issue at +https://github.com/PyPSA/linopy/issues so we can scope it properly. -For 3+ variables, the N−1 pinned tuples are **jointly constrained** to a -single segment position on the piecewise curve. In a CHP characteristic -``(power, fuel, heat)`` with ``sign="<="``, ``fuel`` and ``heat`` trace the -curve simultaneously: specifying ``fuel = 85`` determines the segment -position, which in turn fixes ``heat`` to its curve value at that position. -Assigning ``heat`` to any value inconsistent with the same segment renders -the system infeasible. +**Geometry.** For 2 variables with ``sign="<="`` on a concave curve +:math:`f`, the feasible region is the **hypograph** of :math:`f` on its +domain: + +.. math:: + + \{ (x, y) \ :\ x_0 \le x \le x_n,\ y \le f(x) \}. -The feasible region in N-space is a 2-dimensional manifold: the 1-D -parametric curve at its upper boundary (for ``"<="``), extended along the -first tuple's axis down to that variable's lower bound. +For convex :math:`f` with ``sign=">="`` it is the **epigraph**. Mismatched +sign + curvature (convex + ``"<="``, or concave + ``">="``) describes a +*non-convex* region — ``method="auto"`` falls back to SOS2/incremental +and ``method="lp"`` raises. -**Choice of bounded tuple.** The first tuple should correspond to a +**Choice of bounded tuple.** The bounded tuple should correspond to a quantity with a mechanism for below-curve operation — typically a controllable dissipation path: heat rejection via cooling tower (also -called *thermal curtailment*), electrical curtailment, or emissions after -post-treatment. Placing a consumption-side variable such as fuel intake -in the bounded position yields a valid but **loose** formulation: the -characteristic curve fixes fuel draw at a given load, so ``sign="<="`` on +called *thermal curtailment*), electrical curtailment, or emissions +after post-treatment. Marking a consumption-side variable such as fuel +intake as bounded yields a valid but **loose** formulation: the +characteristic curve fixes fuel draw at a given load, so ``"<="`` on fuel admits operating points the plant cannot physically realise. An objective that rewards lower fuel may then find a non-physical optimum — safe only when no such objective pressure exists. -Relatedly, inequality formulations can also be **faster to solve**: with -2 variables and matching curvature, ``method="auto"`` dispatches to the -pure-LP chord formulation (no SOS2, no binaries). For N≥3 the solver -still reaches for SOS2/incremental, but the relaxed feasible region -often tightens the LP relaxation and reduces branch-and-bound work. -Choose ``sign="=="`` when you want strict curve adherence (the -tightest feasible region) and ``sign="<="`` / ``">="`` when either the -physics admits dissipation or the speedup is worth the relaxation. - **When is a one-sided bound wanted?** -For *continuous* curves, the main reason to reach for ``sign="<="`` / -``">="`` is to unlock the **LP chord formulation** — no SOS2, no -binaries, just pure LP. On a convex/concave curve with a matching sign, -the chord inequalities are as tight as SOS2, so you get the same optimum -with a cheaper model. - -For *disjunctive* curves (``segments(...)``), ``sign`` is a first-class -tool in its own right: disconnected operating regions with a bounded -output, always exact regardless of segment curvature (see the +For *continuous* curves, the main reason to reach for ``"<="`` / ``">="`` +is to unlock the **LP chord formulation** — no SOS2, no binaries, just +pure LP. On a convex/concave curve with a matching sign, the chord +inequalities are as tight as SOS2, so you get the same optimum with a +cheaper model. Inequality formulations also tighten the LP relaxation +of SOS2/incremental, which can reduce branch-and-bound work even when +LP itself is not applicable. + +For *disjunctive* curves (``segments(...)``), the per-tuple sign is a +first-class tool in its own right: disconnected operating regions with a +bounded output, always exact regardless of segment curvature (see the disjunctive section below). -Beyond that: fuel-on-efficiency-envelope modelling (extra burn above the -curve is admissible, cost is still bounded), emissions caps where the curve -is itself a convex overestimator, or any situation where the curve bounds a -variable that need not sit *on* it. - If the curvature doesn't match the sign (convex + ``"<="``, or concave + ``">="``), LP is not applicable — ``method="auto"`` falls back to -SOS2/incremental with the signed output link, which gives a valid but -much more expensive model. In that case prefer ``sign="=="`` unless you -genuinely need the one-sided semantics; the equality formulation is -typically simpler to reason about and no more expensive than the SOS2 -inequality variant. - -**Math (2-variable ``sign="<="``, concave :math:`f`).** The feasible region is -the **hypograph** of :math:`f` restricted to the breakpoint range: - -.. math:: - - \{ (x, y) \ :\ x_0 \le x \le x_n,\ y \le f(x) \}. - -For convex :math:`f` with ``sign=">="``, the feasible region is the epigraph. -Mismatched sign+curvature (convex + ``<=``, or concave + ``>=``) describes a -*non-convex* region — ``method="auto"`` will fall back to SOS2/incremental and -``method="lp"`` will raise. See the -:doc:`piecewise-inequality-bounds-tutorial` notebook for a full walkthrough. +SOS2/incremental with the signed link, which gives a valid but much +more expensive model. In that case prefer ``"=="`` unless you genuinely +need the one-sided semantics. See the +:doc:`piecewise-inequality-bounds-tutorial` notebook for a full +walkthrough. .. warning:: - With ``sign="<="`` and ``active=0``, the output is only bounded **above** by - ``0`` — the lower side still comes from the output variable's own lower - bound. In the common case of non-negative outputs (fuel, cost, heat), set - ``lower=0`` on that variable: combined with the ``y ≤ 0`` constraint from - deactivation, this forces ``y = 0`` automatically. See the docstring for - the full recipe. + With a bounded tuple and ``active=0``, the output is only forced to + ``0`` on the signed side — the complementary bound still comes from + the output variable's own lower/upper bound. In the common case of + non-negative outputs (fuel, cost, heat), set ``lower=0`` on that + variable: combined with the ``y ≤ 0`` constraint from deactivation, + this forces ``y = 0`` automatically. See the docstring for the + full recipe. Breakpoint Construction @@ -275,13 +259,12 @@ For disconnected operating regions (e.g. forbidden zones), use ``segments()``: ) The disjunctive formulation is selected automatically when breakpoints have a -segment dimension. ``sign="<="`` / ``">="`` also works here; the signed link -is applied to the first tuple as usual. +segment dimension. A bounded tuple (``"<="`` / ``">="``) also works here. N-variable linking ~~~~~~~~~~~~~~~~~~ -Link any number of variables through shared breakpoints: +Link any number of variables through shared breakpoints (joint equality): .. code-block:: python @@ -291,9 +274,11 @@ Link any number of variables through shared breakpoints: (heat, [0, 25, 55, 95]), ) -With ``sign="=="`` (default) all variables are symmetric. With a non-equality -sign the first tuple is the bounded output and the rest are forced to -equality. +All variables are symmetric here; every feasible point is the same +``λ``-weighted combination of breakpoints across all three. With 3 or +more tuples, only ``"=="`` signs are accepted — bounding one expression +by a multi-input curve isn't supported yet; see the per-tuple sign +section above for the issue link. Formulation Methods @@ -328,16 +313,16 @@ At-a-glance comparison: - Connected - Connected - Disconnected - * - Supported ``sign`` - - ``==``, ``<=``, ``>=`` - - ``==``, ``<=``, ``>=`` - - ``<=``, ``>=`` only - - ``==``, ``<=``, ``>=`` + * - Supported per-tuple sign + - all ``==`` or one ``<=``/``>=`` + - all ``==`` or one ``<=``/``>=`` + - one ``<=`` or ``>=`` (required) + - all ``==`` or one ``<=``/``>=`` * - Number of tuples - - Any (≥ 2) - - Any (≥ 2) + - ≥ 2 (3+ requires all ``==``) + - ≥ 2 (3+ requires all ``==``) - Exactly 2 - - Any (≥ 2) + - ≥ 2 (3+ requires all ``==``) * - Breakpoint order - Any - Strictly monotonic @@ -381,9 +366,9 @@ Works for any breakpoint ordering. Introduces interpolation weights The SOS2 constraint ensures at most two adjacent :math:`\lambda_i` are non-zero, so every expression is interpolated within the same segment. -With ``sign != "=="`` the input tuples still use the equality above; the -**first** tuple's link is replaced by a one-sided ``e_1 \ \text{sign}\ \sum_i -\lambda_i B_{1,i}`` constraint. +With a bounded tuple, the pinned tuples still use the equality above; the +bounded tuple's link is replaced by a one-sided ``e_b \ \text{sign}\ \sum_i +\lambda_i B_{b,i}`` constraint. .. note:: @@ -408,8 +393,8 @@ For **strictly monotonic** breakpoints. Uses fill-fraction variables &e_j = B_{j,0} + \sum_{i=1}^{n} \delta_i \, (B_{j,i} - B_{j,i-1}) -With ``sign != "=="`` the same sign split as SOS2 applies: inputs use the -equality above; the first tuple's link uses the requested sign. +With a bounded tuple the same split as SOS2 applies: pinned tuples use the +equality above; the bounded tuple's link uses the requested sign. .. code-block:: python @@ -445,12 +430,12 @@ this and falls back; ``method="lp"`` raises. .. code-block:: python # y <= f(x) on a concave f — auto picks LP - m.add_piecewise_formulation((y, yp), (x, xp), sign="<=") + m.add_piecewise_formulation((y, yp, "<="), (x, xp)) # Or explicitly: - m.add_piecewise_formulation((y, yp), (x, xp), sign="<=", method="lp") + m.add_piecewise_formulation((y, yp, "<="), (x, xp), method="lp") -**Not supported with** ``method="lp"``: ``sign="=="``, more than 2 tuples, +**Not supported with** ``method="lp"``: all-equality, more than 2 tuples, and ``active``. ``method="auto"`` falls back to SOS2/incremental in all three cases. @@ -476,14 +461,14 @@ indicators :math:`z_k` select exactly one segment; SOS2 applies within it: No big-M constants are needed, giving a tight LP relaxation. -**Disjunctive + ``sign``.** ``sign="<="`` / ``">="`` works here too, -applied to the first tuple exactly as for the continuous methods. -Because the disjunctive machinery already carries a per-segment binary, -there is **no curvature requirement** on the segments — inequality is -always exact on the hypograph (or epigraph) of the active segment, -whatever its slope pattern. This makes disjunctive + sign a first-class -tool for "bounded output on disconnected operating regions" that -``method="lp"`` cannot handle. +**Disjunctive + bounded tuple.** A per-tuple ``"<="`` / ``">="`` works +here too, applied to the bounded tuple exactly as for the continuous +methods. Because the disjunctive machinery already carries a +per-segment binary, there is **no curvature requirement** on the +segments — inequality is always exact on the hypograph (or epigraph) of +the active segment, whatever its slope pattern. This makes disjunctive +plus a bounded tuple a first-class tool for "bounded output on +disconnected operating regions" that ``method="lp"`` cannot handle. Advanced Features @@ -512,11 +497,11 @@ Not supported with ``method="lp"``. .. note:: - With a non-equality ``sign``, deactivation only pushes the signed bound to + With a bounded tuple, deactivation only pushes the signed bound to ``0`` — the complementary side comes from the output variable's own lower/upper bound. Set ``lower=0`` on naturally non-negative outputs - (fuel, cost, heat) to pin the output to zero on deactivation. See the - ``sign`` section above for details. + (fuel, cost, heat) to pin the output to zero on deactivation. See + the per-tuple sign section above for details. Auto-broadcasting ~~~~~~~~~~~~~~~~~ @@ -557,10 +542,10 @@ each formulation creates a predictable set of names: - ``{N}_lambda`` — variable, interpolation weights - ``{N}_convex`` — constraint, ``sum(lambda) == 1`` (or ``== active``) -- ``{N}_link`` — constraint, equality link (stacked inputs when - ``sign != "=="``, all tuples when ``sign="=="``) -- ``{N}_output_link`` — constraint, signed link on the first tuple - *(only when* ``sign != "=="`` *)* +- ``{N}_link`` — constraint, equality link (pinned tuples when one tuple + is bounded; all tuples when all are equality) +- ``{N}_output_link`` — constraint, signed link on the bounded tuple + *(only when one tuple carries* ``"<="`` */* ``">="`` *)* **Incremental** (``method="incremental"``): @@ -593,6 +578,6 @@ See Also - :doc:`piecewise-linear-constraints-tutorial` — worked examples of the equality API (notebook) -- :doc:`piecewise-inequality-bounds-tutorial` — the ``sign`` parameter, the LP - formulation and the first-tuple convention (notebook) +- :doc:`piecewise-inequality-bounds-tutorial` — per-tuple sign and the LP + formulation (notebook) - :doc:`sos-constraints` — low-level SOS1/SOS2 constraint API diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 621ea0ca..34292d36 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_formulation()`` for piecewise linear equality constraints with SOS2, incremental, and disjunctive formulations: ``m.add_piecewise_formulation((power, x_pts), (fuel, y_pts))``. Supports N-variable linking (e.g. CHP with fuel/power/heat) and per-entity breakpoints. ``method="auto"`` picks the cheapest correct formulation automatically. The API is newly added and emits an :class:`linopy.EvolvingAPIWarning` to signal that details (e.g. the ``sign``/first-tuple convention, ``active`` + non-equality sign semantics) may be refined in minor releases — feedback and use cases at https://github.com/PyPSA/linopy/issues shape what stabilises. Silence with ``warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)``. -* Add one-sided piecewise bounds via the ``sign`` parameter on ``add_piecewise_formulation``: ``sign="<="`` / ``">="`` applies the bound to the first tuple (first-tuple convention). On convex/concave curves with a matching sign, ``method="auto"`` dispatches to a pure-LP chord formulation (``method="lp"``) with no auxiliary variables and automatic domain bounds on the input. Mismatched curvature+sign is detected and falls back to SOS2/incremental with an explanatory info log. +* Add ``add_piecewise_formulation()`` for piecewise linear constraints with SOS2, incremental, and disjunctive formulations: ``m.add_piecewise_formulation((power, x_pts), (fuel, y_pts))``. Supports N-variable linking (e.g. CHP with fuel/power/heat) and per-entity breakpoints. ``method="auto"`` picks the cheapest correct formulation automatically. The API is newly added and emits an :class:`linopy.EvolvingAPIWarning` to signal that details (e.g. the per-tuple sign convention, ``active`` + non-equality sign semantics) may be refined in minor releases — feedback and use cases at https://github.com/PyPSA/linopy/issues shape what stabilises. Silence with ``warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)``. +* Add one-sided piecewise bounds via a per-tuple sign on ``add_piecewise_formulation``: append ``"<="`` or ``">="`` as a third tuple element — e.g. ``(fuel, y_pts, "<=")`` — to mark that expression as bounded by the curve while the others remain pinned. At most one tuple may carry a non-equality sign; with 3 or more tuples all signs must be ``"=="``. On convex/concave curves with a matching sign, ``method="auto"`` dispatches to a pure-LP chord formulation (``method="lp"``) with no auxiliary variables and automatic domain bounds on the input. Mismatched curvature+sign is detected and falls back to SOS2/incremental with an explanatory info log. * Add unit-commitment gating via the ``active`` parameter on ``add_piecewise_formulation``: a binary variable that, when zero, forces all auxiliary variables (and thus the linked expressions) to zero. Works with the SOS2, incremental, and disjunctive methods. * Surface formulation metadata on the returned ``PiecewiseFormulation``: ``.method`` (resolved method name) and ``.convexity`` (``"convex"`` / ``"concave"`` / ``"linear"`` / ``"mixed"`` when well-defined). Both persist across netCDF round-trip. -* Add ``tangent_lines()`` as a low-level helper that returns per-segment chord expressions as a ``LinearExpression`` — no variables created. Most users should prefer ``add_piecewise_formulation(..., sign="<=")``, which builds on this helper and adds domain bounds and curvature validation. +* Add ``tangent_lines()`` as a low-level helper that returns per-segment chord expressions as a ``LinearExpression`` — no variables created. Most users should prefer ``add_piecewise_formulation`` with a bounded tuple ``(y, y_pts, "<=")``, which builds on this helper and adds domain bounds and curvature validation. * 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 ``slopes_to_points()`` utility for converting segment slopes to breakpoint y-coordinates. diff --git a/examples/piecewise-inequality-bounds.ipynb b/examples/piecewise-inequality-bounds.ipynb index e6684b62..be5746f0 100644 --- a/examples/piecewise-inequality-bounds.ipynb +++ b/examples/piecewise-inequality-bounds.ipynb @@ -3,35 +3,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "# Piecewise inequalities — the `sign` parameter\n", - "\n", - "`add_piecewise_formulation` accepts a ``sign`` parameter to express one-sided\n", - "bounds of the form `y ≤ f(x)` or `y ≥ f(x)`:\n", - "\n", - "```python\n", - "m.add_piecewise_formulation(\n", - " (fuel, y_pts), # output — gets the sign\n", - " (power, x_pts), # input — always equality\n", - " sign=\"<=\",\n", - ")\n", - "```\n", - "\n", - "This notebook walks through the math, the **first-tuple convention**, and\n", - "the feasible regions produced by each method (LP, SOS2, incremental).\n", - "\n", - "## Key points\n", - "\n", - "| | Behaviour |\n", - "|---|---|\n", - "| `sign=\"==\"` (default) | All expressions lie exactly on the curve. |\n", - "| `sign=\"<=\"` | First expression is bounded above by `f(rest)`. |\n", - "| `sign=\">=\"` | First expression is bounded below by `f(rest)`. |\n", - "\n", - "**First-tuple convention**: only the *first* tuple's variable gets the sign.\n", - "All remaining tuples are equality (inputs on the curve). This restriction\n", - "keeps the semantics unambiguous — it's always \"output sign function(inputs)\"." - ] + "source": "# Piecewise inequalities — per-tuple sign\n\n`add_piecewise_formulation` accepts an optional third tuple element, `\"<=\"` or `\">=\"`, that marks one expression as **bounded** by the piecewise curve instead of pinned to it:\n\n```python\nm.add_piecewise_formulation(\n (fuel, y_pts, \"<=\"), # bounded above by the curve\n (power, x_pts), # pinned to the curve\n)\n```\n\nThis notebook walks through the math, the curvature × sign matching that lets `method=\"auto\"` skip MIP machinery entirely, and the feasible regions produced by each method (LP, SOS2, incremental).\n\n## Key points\n\n| Tuple form | Behaviour |\n|---|---|\n| `(expr, breaks)` | Pinned: `expr` lies exactly on the curve. |\n| `(expr, breaks, \"<=\")` | Bounded above: `expr ≤ f(other tuples)`. |\n| `(expr, breaks, \">=\")` | Bounded below: `expr ≥ f(other tuples)`. |\n\nCurrently at most one tuple may carry a non-equality sign, and 3+ tuples must all be equality. Multi-bounded and N≥3 inequality cases aren't supported yet — if you have a concrete use case, please open an issue at https://github.com/PyPSA/linopy/issues so we can scope it properly." }, { "cell_type": "code", @@ -53,64 +25,12 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Mathematical formulation\n", - "\n", - "### Equality (`sign=\"==\"`)\n", - "\n", - "For $N$ expressions $e_1, \\dots, e_N$ with breakpoints $B_{j,0}, \\dots, B_{j,n}$ per expression $j$, the SOS2 formulation introduces interpolation weights $\\lambda_i \\in [0,1]$:\n", - "\n", - "$$\n", - "\\sum_{i=0}^{n} \\lambda_i = 1, \\qquad \\text{SOS2}(\\lambda_0, \\dots, \\lambda_n),\n", - "\\qquad e_j = \\sum_{i=0}^{n} \\lambda_i \\, B_{j,i} \\ \\ \\forall j.\n", - "$$\n", - "\n", - "Every expression is tied to the same $\\lambda$ — they share a single point on the curve.\n", - "\n", - "### Inequality (`sign=\"<=\"` or `\">=\"`, first-tuple convention)\n", - "\n", - "The *first* expression $e_1$ is the output; the rest are inputs forced to equality:\n", - "\n", - "$$\n", - "\\sum_{i=0}^{n} \\lambda_i = 1, \\qquad \\text{SOS2}(\\lambda),\n", - "\\qquad e_j = \\sum_{i=0}^{n} \\lambda_i \\, B_{j,i}\\ \\ \\forall j \\ge 2,\n", - "\\qquad e_1 \\ \\text{sign}\\ \\sum_{i=0}^{n} \\lambda_i \\, B_{1,i}.\n", - "$$\n", - "\n", - "Inputs $e_2, \\dots, e_N$ are pinned to the curve at a shared $\\lambda$; the output $e_1$ is then bounded (above or below) by the interpolated value. The internal split is visible in the generated constraints: a single stacked `*_link` for inputs and a separate `*_output_link` carrying the sign.\n", - "\n", - "### LP method (2-variable inequality, convex/concave curve)\n", - "\n", - "For $y \\le f(x)$ on a concave $f$ (or $y \\ge f(x)$ on convex), we add one tangent (chord) per segment $k$:\n", - "\n", - "$$\n", - "y \\le m_k \\cdot x + c_k \\ \\ \\forall k,\n", - "\\qquad x_0 \\le x \\le x_n,\n", - "$$\n", - "\n", - "where $m_k = (y_{k+1}-y_k)/(x_{k+1}-x_k)$ and $c_k = y_k - m_k x_k$. The intersection of all chord inequalities equals the hypograph within the x-domain. No auxiliary variables are created.\n", - "\n", - "### Incremental (delta) formulation\n", - "\n", - "An MIP alternative to SOS2 for strictly monotonic breakpoints, using fill fractions $\\delta_i \\in [0,1]$ and binaries $z_i$ per segment:\n", - "\n", - "$$\n", - "\\delta_{i+1} \\le \\delta_i, \\quad z_{i+1} \\le \\delta_i, \\quad \\delta_i \\le z_i,\n", - "\\qquad e_j = B_{j,0} + \\sum_i \\delta_i\\,(B_{j,i+1}-B_{j,i}).\n", - "$$\n", - "\n", - "Same sign split as SOS2: inputs use equality, output uses the requested sign." - ] + "source": "## Mathematical formulation\n\n### All-equality (every tuple pinned)\n\nFor $N$ expressions $e_1, \\dots, e_N$ with breakpoints $B_{j,0}, \\dots, B_{j,n}$ per expression $j$, the SOS2 formulation introduces interpolation weights $\\lambda_i \\in [0,1]$:\n\n$$\n\\sum_{i=0}^{n} \\lambda_i = 1, \\qquad \\text{SOS2}(\\lambda_0, \\dots, \\lambda_n),\n\\qquad e_j = \\sum_{i=0}^{n} \\lambda_i \\, B_{j,i} \\ \\ \\forall j.\n$$\n\nEvery expression is tied to the same $\\lambda$ — they share a single point on the curve.\n\n### One bounded tuple\n\nWhen tuple $b$ carries a non-equality sign, its link becomes one-sided; the pinned tuples keep the equality:\n\n$$\n\\sum_{i=0}^{n} \\lambda_i = 1, \\qquad \\text{SOS2}(\\lambda),\n\\qquad e_j = \\sum_{i=0}^{n} \\lambda_i \\, B_{j,i}\\ \\ \\forall j \\ne b,\n\\qquad e_b \\ \\text{sign}\\ \\sum_{i=0}^{n} \\lambda_i \\, B_{b,i}.\n$$\n\nThe pinned expressions are tied to a shared $\\lambda$; the bounded one is then bounded (above or below) by the interpolated value. The split is visible in the generated constraints: a single stacked `*_link` for pinned tuples and a separate `*_output_link` carrying the sign.\n\n### LP method (2-variable inequality, convex/concave curve)\n\nFor $y \\le f(x)$ on a concave $f$ (or $y \\ge f(x)$ on convex), we add one tangent (chord) per segment $k$:\n\n$$\ny \\le m_k \\cdot x + c_k \\ \\ \\forall k,\n\\qquad x_0 \\le x \\le x_n,\n$$\n\nwhere $m_k = (y_{k+1}-y_k)/(x_{k+1}-x_k)$ and $c_k = y_k - m_k x_k$. The intersection of all chord inequalities equals the hypograph within the x-domain. No auxiliary variables are created.\n\n### Incremental (delta) formulation\n\nAn MIP alternative to SOS2 for strictly monotonic breakpoints, using fill fractions $\\delta_i \\in [0,1]$ and binaries $z_i$ per segment:\n\n$$\n\\delta_{i+1} \\le \\delta_i, \\quad z_{i+1} \\le \\delta_i, \\quad \\delta_i \\le z_i,\n\\qquad e_j = B_{j,0} + \\sum_i \\delta_i\\,(B_{j,i+1}-B_{j,i}).\n$$\n\nSame split as SOS2: pinned tuples use equality; the bounded one uses the requested sign." }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Setup — a concave curve\n", - "\n", - "We use a concave, monotonically increasing curve. With `sign=\"<=\"`, the LP\n", - "method is applicable (concave + `<=` is a tight relaxation)." - ] + "source": "## Setup — a concave curve\n\nWe use a concave, monotonically increasing curve. With a tuple bounded `<=`, the LP method is applicable (concave + `<=` is a tight relaxation)." }, { "cell_type": "code", @@ -136,23 +56,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Three methods, identical feasible region\n", - "\n", - "With `sign=\"<=\"` and our concave curve, the three methods give the **same**\n", - "feasible region within `[x_0, x_n]`:\n", - "\n", - "- **`method=\"lp\"`** — tangent lines + domain bounds. No auxiliary variables.\n", - "- **`method=\"sos2\"`** — lambdas + SOS2 + split link (input equality, output\n", - " signed). Solver picks the segment.\n", - "- **`method=\"incremental\"`** — delta fractions + binaries + split link.\n", - " Same mathematics, MIP encoding instead of SOS2.\n", - "\n", - "`method=\"auto\"` dispatches to `\"lp\"` whenever applicable — it's always\n", - "preferable because it's pure LP.\n", - "\n", - "Let's verify they produce the same solution at `power=15`." - ] + "source": "## Three methods, identical feasible region\n\nWith one tuple bounded `<=` and our concave curve, the three methods give the **same** feasible region within `[x_0, x_n]`:\n\n- **`method=\"lp\"`** — tangent lines + domain bounds. No auxiliary variables.\n- **`method=\"sos2\"`** — lambdas + SOS2 + split link (pinned equality, bounded signed). Solver picks the segment.\n- **`method=\"incremental\"`** — delta fractions + binaries + split link. Same mathematics, MIP encoding instead of SOS2.\n\n`method=\"auto\"` dispatches to `\"lp\"` whenever applicable — it's always preferable because it's pure LP.\n\nLet's verify they produce the same solution at `power=15`." }, { "cell_type": "code", @@ -164,59 +68,17 @@ } }, "outputs": [], - "source": [ - "def solve(method, power_val):\n", - " m = linopy.Model()\n", - " power = m.add_variables(lower=0, upper=30, name=\"power\")\n", - " fuel = m.add_variables(lower=0, upper=40, name=\"fuel\")\n", - " m.add_piecewise_formulation(\n", - " (fuel, y_pts), # output, signed\n", - " (power, x_pts), # input, ==\n", - " sign=\"<=\",\n", - " method=method,\n", - " )\n", - " m.add_constraints(power == power_val)\n", - " m.add_objective(-fuel) # maximise fuel to push against the bound\n", - " m.solve()\n", - " return float(m.solution[\"fuel\"]), list(m.variables), list(m.constraints)\n", - "\n", - "\n", - "for method in [\"lp\", \"sos2\", \"incremental\"]:\n", - " fuel_val, vars_, cons_ = solve(method, 15)\n", - " print(f\"{method:12}: fuel={fuel_val:.2f} vars={vars_} cons={cons_}\")" - ] + "source": "def solve(method, power_val):\n m = linopy.Model()\n power = m.add_variables(lower=0, upper=30, name=\"power\")\n fuel = m.add_variables(lower=0, upper=40, name=\"fuel\")\n m.add_piecewise_formulation(\n (fuel, y_pts, \"<=\"), # bounded\n (power, x_pts), # pinned\n method=method,\n )\n m.add_constraints(power == power_val)\n m.add_objective(-fuel) # maximise fuel to push against the bound\n m.solve()\n return float(m.solution[\"fuel\"]), list(m.variables), list(m.constraints)\n\n\nfor method in [\"lp\", \"sos2\", \"incremental\"]:\n fuel_val, vars_, cons_ = solve(method, 15)\n print(f\"{method:12}: fuel={fuel_val:.2f} vars={vars_} cons={cons_}\")" }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "All three give `fuel=25` at `power=15` (which is `f(15)` exactly) — the math\n", - "is equivalent. The LP method is strictly cheaper: no auxiliary variables,\n", - "just three chord constraints and two domain bounds.\n", - "\n", - "The SOS2 and incremental methods create lambdas (or deltas + binaries) and\n", - "split the link into an input-equality constraint plus a signed output link —\n", - "but the feasible region is the same." - ] + "source": "All three give `fuel=25` at `power=15` (which is `f(15)` exactly) — the math is equivalent. The LP method is strictly cheaper: no auxiliary variables, just three chord constraints and two domain bounds.\n\nThe SOS2 and incremental methods create lambdas (or deltas + binaries) and split the link into a pinned-equality constraint plus a signed bounded link — but the feasible region is the same." }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Visualising the feasible region\n", - "\n", - "The feasible region for `(power, fuel)` with `sign=\"<=\"` is the **hypograph**\n", - "of `f` restricted to the curve's x-domain:\n", - "\n", - "$$\\{ (x, y) : x_0 \\le x \\le x_n,\\ y \\le f(x) \\}$$\n", - "\n", - "We colour green feasible points, red infeasible ones. Three test points:\n", - "\n", - "- `(15, 15)` — inside the curve, `15 ≤ f(15)=25` ✓\n", - "- `(15, 25)` — on the curve ✓\n", - "- `(15, 29)` — above `f(15)`, should be infeasible ✗\n", - "- `(35, 20)` — power beyond domain, infeasible ✗" - ] + "source": "## Visualising the feasible region\n\nThe feasible region for `(power, fuel)` with `fuel` bounded `<=` is the **hypograph** of `f` restricted to the curve's x-domain:\n\n$$\\{ (x, y) : x_0 \\le x \\le x_n,\\ y \\le f(x) \\}$$\n\nWe colour green feasible points, red infeasible ones. Three test points:\n\n- `(15, 15)` — inside the curve, `15 ≤ f(15)=25` ✓\n- `(15, 25)` — on the curve ✓\n- `(15, 29)` — above `f(15)`, should be infeasible ✗\n- `(35, 20)` — power beyond domain, infeasible ✗" }, { "cell_type": "code", @@ -262,191 +124,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "from mpl_toolkits.mplot3d.art3d import Poly3DCollection\n", - "\n", - "x_pts_3d = np.array([0.0, 30.0, 60.0, 100.0]) # power\n", - "z_pts_3d = np.array([0.0, 25.0, 55.0, 95.0]) # heat\n", - "y_pts_3d = np.array([0.0, 40.0, 85.0, 160.0]) # fuel (output)\n", - "\n", - "# Dense parameterisation of the 1-D curve\n", - "t_grid = np.linspace(0, len(x_pts_3d) - 1, 80)\n", - "power_c = np.interp(t_grid, np.arange(len(x_pts_3d)), x_pts_3d)\n", - "heat_c = np.interp(t_grid, np.arange(len(z_pts_3d)), z_pts_3d)\n", - "fuel_c = np.interp(t_grid, np.arange(len(y_pts_3d)), y_pts_3d)\n", - "\n", - "fig = plt.figure(figsize=(7, 6))\n", - "ax = fig.add_subplot(111, projection=\"3d\")\n", - "\n", - "# Shaded ribbon: (power(t), heat(t), fuel) for fuel from 0 to f(t)\n", - "polys = []\n", - "for i in range(len(t_grid) - 1):\n", - " quad = [\n", - " (power_c[i], heat_c[i], 0.0),\n", - " (power_c[i + 1], heat_c[i + 1], 0.0),\n", - " (power_c[i + 1], heat_c[i + 1], fuel_c[i + 1]),\n", - " (power_c[i], heat_c[i], fuel_c[i]),\n", - " ]\n", - " polys.append(quad)\n", - "coll = Poly3DCollection(polys, facecolor=\"lightsteelblue\", edgecolor=\"none\", alpha=0.35)\n", - "ax.add_collection3d(coll)\n", - "\n", - "# Upper boundary: the curve itself\n", - "ax.plot(power_c, heat_c, fuel_c, color=\"C0\", lw=2.5, label=\"curve f(t)\")\n", - "ax.scatter(x_pts_3d, z_pts_3d, y_pts_3d, color=\"C0\", s=50)\n", - "\n", - "# Lower boundary at fuel = 0\n", - "ax.plot(power_c, heat_c, 0 * fuel_c, color=\"gray\", lw=1, linestyle=\":\", alpha=0.7,\n", - " label=\"projection in (power, heat)\")\n", - "\n", - "ax.set(xlabel=\"power\", ylabel=\"heat\", zlabel=\"fuel\",\n", - " title=\"sign='<=' feasible region for 3 variables\")\n", - "ax.view_init(elev=20, azim=-70)\n", - "ax.legend()\n", - "plt.tight_layout()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-22T19:57:00.150636Z", - "start_time": "2026-04-22T19:57:00.012662Z" - } - }, - "outputs": [], - "source": [ - "from mpl_toolkits.mplot3d.art3d import Poly3DCollection\n", - "\n", - "x_pts_3d = np.array([0.0, 30.0, 60.0, 100.0]) # power\n", - "z_pts_3d = np.array([0.0, 25.0, 55.0, 95.0]) # heat\n", - "y_pts_3d = np.array([0.0, 40.0, 85.0, 160.0]) # fuel (output)\n", - "\n", - "# Dense parameterisation of the 1-D curve\n", - "t_grid = np.linspace(0, len(x_pts_3d) - 1, 80)\n", - "power_c = np.interp(t_grid, np.arange(len(x_pts_3d)), x_pts_3d)\n", - "heat_c = np.interp(t_grid, np.arange(len(z_pts_3d)), z_pts_3d)\n", - "fuel_c = np.interp(t_grid, np.arange(len(y_pts_3d)), y_pts_3d)\n", - "\n", - "\n", - "def draw_ribbon(ax, elev, azim, title):\n", - " # Shaded ribbon: (power(t), heat(t), fuel) for fuel from 0 to f(t)\n", - " polys = []\n", - " for i in range(len(t_grid) - 1):\n", - " quad = [\n", - " (power_c[i], heat_c[i], 0.0),\n", - " (power_c[i + 1], heat_c[i + 1], 0.0),\n", - " (power_c[i + 1], heat_c[i + 1], fuel_c[i + 1]),\n", - " (power_c[i], heat_c[i], fuel_c[i]),\n", - " ]\n", - " polys.append(quad)\n", - " coll = Poly3DCollection(\n", - " polys, facecolor=\"lightsteelblue\", edgecolor=\"none\", alpha=0.35\n", - " )\n", - " ax.add_collection3d(coll)\n", - "\n", - " # Upper boundary: the curve itself\n", - " ax.plot(power_c, heat_c, fuel_c, color=\"C0\", lw=2.5)\n", - " ax.scatter(x_pts_3d, z_pts_3d, y_pts_3d, color=\"C0\", s=40)\n", - "\n", - " # (power, heat) projection at fuel=0\n", - " ax.plot(power_c, heat_c, 0 * fuel_c, color=\"gray\", lw=1, linestyle=\":\", alpha=0.7)\n", - "\n", - " ax.set(xlabel=\"power\", ylabel=\"heat\", zlabel=\"fuel\", title=title)\n", - " ax.view_init(elev=elev, azim=azim)\n", - "\n", - "\n", - "fig = plt.figure(figsize=(13, 5))\n", - "ax1 = fig.add_subplot(131, projection=\"3d\")\n", - "draw_ribbon(ax1, elev=20, azim=-70, title=\"perspective\")\n", - "ax2 = fig.add_subplot(132, projection=\"3d\")\n", - "draw_ribbon(ax2, elev=0, azim=-90, title=\"side (power vs fuel)\")\n", - "ax3 = fig.add_subplot(133, projection=\"3d\")\n", - "draw_ribbon(ax3, elev=90, azim=-90, title=\"top (power vs heat)\")\n", - "plt.tight_layout()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The ribbon has the shape *(input curve)* × *[0, output value]*. Points *above*\n", - "the curve in fuel are infeasible; points *below* are feasible, provided\n", - "`(power, heat)` lies on the curve's projection. If the user tried to set\n", - "`power=50, heat=20` (off-curve), the formulation would be infeasible — the\n", - "inputs must be consistent with a shared $\\lambda$." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## The first-tuple convention\n", - "\n", - "Why does only *one* variable get the sign? Because the math of\n", - "\"bound one output by a function of the others\" has a single inequality\n", - "direction. For 3+ variables:\n", - "\n", - "```python\n", - "m.add_piecewise_formulation(\n", - " (fuel, y_pts), # output — sign\n", - " (power, x_pts), # input — ==\n", - " (heat, z_pts), # input — ==\n", - " sign=\"<=\",\n", - ")\n", - "```\n", - "\n", - "reads as `fuel ≤ g(power, heat)` on the joint curve. All inputs must lie on\n", - "the curve (equality); only the output is bounded.\n", - "\n", - "Allowing arbitrary per-variable signs would open up cases like\n", - "\"`fuel ≤ f(power)` AND `heat ≤ f(power)`\" which is a dominated region, not a\n", - "hypograph — mathematically valid but rarely what users want. Restricting to\n", - "one output keeps the semantics unambiguous." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## When is LP the right choice?\n", - "\n", - "`tangent_lines` imposes the **intersection** of chord inequalities. Whether\n", - "that intersection matches the true hypograph/epigraph of `f` depends on the\n", - "curvature × sign combination:\n", - "\n", - "| curvature | `sign=\"<=\"` | `sign=\">=\"` |\n", - "|-----------|-------------|-------------|\n", - "| **concave** | **hypograph (exact ✓)** | **wrong region** — requires `y ≥ max_k chord_k(x) > f(x)` |\n", - "| **convex** | **wrong region** — requires `y ≤ min_k chord_k(x) < f(x)` | **epigraph (exact ✓)** |\n", - "| linear | exact | exact |\n", - "| mixed (non-convex) | convex hull of `f` (wrong for exact hypograph) | concave hull of `f` (wrong for exact epigraph) |\n", - "\n", - "In the ✗ cases, tangent lines do **not** give a loose relaxation — they give\n", - "a **strictly wrong feasible region** that rejects points satisfying the true\n", - "constraint. Example: for a concave `f` with `y ≥ f(x)`, the chord of any\n", - "segment extrapolated over another segment's x-range lies *above* `f`, so the\n", - "constraint `y ≥ max_k chord_k(x)` forbids `y = f(x)` itself.\n", - "\n", - "`method=\"auto\"` dispatches to LP only in the two **exact** cases (concave+`<=`\n", - "or convex+`>=`). For the other combinations it falls back to SOS2 or\n", - "incremental, which encode the hypograph/epigraph exactly via discrete segment\n", - "selection.\n", - "\n", - "`method=\"lp\"` explicitly forces LP and raises on a mismatched curvature\n", - "rather than silently producing a wrong feasible region.\n", - "\n", - "For **non-convex** curves with either sign, the only exact option is a\n", - "piecewise formulation. That's what `sign=\"<=\"` does internally: it falls\n", - "back to SOS2/incremental with the sign on the output link. No relaxation,\n", - "no wrong bounds.\n", - "\n", - "For **3+ variables** with inequality, LP is never applicable: tangent lines\n", - "express *one input → one output*. With multiple inputs on a 1-D curve in\n", - "N-D space, identifying which segment we're on requires SOS2/binary. Auto\n", - "dispatches to SOS2 here." - ] + "source": "## When is LP the right choice?\n\n`tangent_lines` imposes the **intersection** of chord inequalities. Whether that intersection matches the true hypograph/epigraph of `f` depends on the curvature × sign combination:\n\n| curvature | bounded `<=` | bounded `>=` |\n|-----------|--------------|--------------|\n| **concave** | **hypograph (exact ✓)** | **wrong region** — requires `y ≥ max_k chord_k(x) > f(x)` |\n| **convex** | **wrong region** — requires `y ≤ min_k chord_k(x) < f(x)` | **epigraph (exact ✓)** |\n| linear | exact | exact |\n| mixed (non-convex) | convex hull of `f` (wrong for exact hypograph) | concave hull of `f` (wrong for exact epigraph) |\n\nIn the ✗ cases, tangent lines do **not** give a loose relaxation — they give a **strictly wrong feasible region** that rejects points satisfying the true constraint. Example: for a concave `f` with `y ≥ f(x)`, the chord of any segment extrapolated over another segment's x-range lies *above* `f`, so `y ≥ max_k chord_k(x)` forbids `y = f(x)` itself.\n\n`method=\"auto\"` dispatches to LP only in the two **exact** cases (concave + `<=` or convex + `>=`). For the other combinations it falls back to SOS2 or incremental, which encode the hypograph/epigraph exactly via discrete segment selection.\n\n`method=\"lp\"` explicitly forces LP and raises on a mismatched curvature rather than silently producing a wrong feasible region.\n\nFor **non-convex** curves with either sign, the only exact option is a piecewise formulation. That's what the bounded-tuple path does internally: it falls back to SOS2/incremental with the sign on the bounded link. No relaxation, no wrong bounds." }, { "cell_type": "code", @@ -458,51 +136,12 @@ } }, "outputs": [], - "source": [ - "# 1. Non-convex curve: auto falls back (LP relaxation would be loose)\n", - "x_nc = [0, 10, 20, 30]\n", - "y_nc = [0, 20, 10, 30] # slopes change sign → mixed convexity\n", - "\n", - "m1 = linopy.Model()\n", - "x1 = m1.add_variables(lower=0, upper=30, name=\"x\")\n", - "y1 = m1.add_variables(lower=0, upper=40, name=\"y\")\n", - "f1 = m1.add_piecewise_formulation((y1, y_nc), (x1, x_nc), sign=\"<=\")\n", - "print(f\"non-convex + '<=' → {f1.method}\")\n", - "\n", - "# 2. Concave curve + sign='>=': LP would be loose → auto falls back to MIP\n", - "x_cc = [0, 10, 20, 30]\n", - "y_cc = [0, 20, 30, 35] # concave\n", - "\n", - "m2 = linopy.Model()\n", - "x2 = m2.add_variables(lower=0, upper=30, name=\"x\")\n", - "y2 = m2.add_variables(lower=0, upper=40, name=\"y\")\n", - "f2 = m2.add_piecewise_formulation((y2, y_cc), (x2, x_cc), sign=\">=\")\n", - "print(f\"concave + '>=' → {f2.method}\")\n", - "\n", - "# 3. Explicit method=\"lp\" with mismatched curvature raises\n", - "try:\n", - " m3 = linopy.Model()\n", - " x3 = m3.add_variables(lower=0, upper=30, name=\"x\")\n", - " y3 = m3.add_variables(lower=0, upper=40, name=\"y\")\n", - " m3.add_piecewise_formulation((y3, y_cc), (x3, x_cc), sign=\">=\", method=\"lp\")\n", - "except ValueError as e:\n", - " print(f\"lp(concave, '>=') → raises: {e}\")" - ] + "source": "# 1. Non-convex curve: auto falls back (LP relaxation would be loose)\nx_nc = [0, 10, 20, 30]\ny_nc = [0, 20, 10, 30] # slopes change sign → mixed convexity\n\nm1 = linopy.Model()\nx1 = m1.add_variables(lower=0, upper=30, name=\"x\")\ny1 = m1.add_variables(lower=0, upper=40, name=\"y\")\nf1 = m1.add_piecewise_formulation((y1, y_nc, \"<=\"), (x1, x_nc))\nprint(f\"non-convex + '<=' → {f1.method}\")\n\n# 2. Concave curve + sign='>=': LP would be loose → auto falls back to MIP\nx_cc = [0, 10, 20, 30]\ny_cc = [0, 20, 30, 35] # concave\n\nm2 = linopy.Model()\nx2 = m2.add_variables(lower=0, upper=30, name=\"x\")\ny2 = m2.add_variables(lower=0, upper=40, name=\"y\")\nf2 = m2.add_piecewise_formulation((y2, y_cc, \">=\"), (x2, x_cc))\nprint(f\"concave + '>=' → {f2.method}\")\n\n# 3. Explicit method=\"lp\" with mismatched curvature raises\ntry:\n m3 = linopy.Model()\n x3 = m3.add_variables(lower=0, upper=30, name=\"x\")\n y3 = m3.add_variables(lower=0, upper=40, name=\"y\")\n m3.add_piecewise_formulation((y3, y_cc, \">=\"), (x3, x_cc), method=\"lp\")\nexcept ValueError as e:\n print(f\"lp(concave, '>=') → raises: {e}\")" }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Summary\n", - "\n", - "- Use `sign=\"=\"` (default) for exact equality on the curve.\n", - "- Use `sign=\"<=\"` / `sign=\">=\"` for one-sided bounds on the first tuple's\n", - " expression.\n", - "- `method=\"auto\"` picks the most efficient formulation: LP for convex/concave\n", - " 2-variable inequalities, otherwise SOS2 or incremental.\n", - "- Only the *first* tuple gets the sign — all other tuples are always\n", - " equality. This restriction keeps semantics unambiguous." - ] + "source": "## Summary\n\n- Default is all-equality: every tuple lies on the curve.\n- Append `\"<=\"` or `\">=\"` as a third tuple element to mark one expression as bounded by the curve.\n- `method=\"auto\"` picks the most efficient formulation: LP for matching-curvature 2-variable inequalities, otherwise SOS2 or incremental.\n- At most one tuple may be bounded; with 3+ tuples all must be equality. Multi-bounded and N≥3 inequality use cases — please open an issue at https://github.com/PyPSA/linopy/issues so we can scope them." } ], "metadata": { diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index a8035e50..be891b85 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -186,18 +186,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "## 4. Inequality bounds — `sign=\"<=\"`\n", - "\n", - "`sign=\"<=\"` / `\">=\"` relaxes **one** tuple into a one-sided bound; the remaining N−1 tuples stay pinned to the curve and move together along it in lockstep.\n", - "\n", - "- With 2 tuples, this is the familiar hypograph `{(x, y) : y ≤ f(x)}`.\n", - "- With 3+ tuples, the N−1 \"pinned\" inputs cannot be constrained independently — they share a single curve-segment position.\n", - "\n", - "On a concave curve with `<=` (or convex with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation — no binaries, no SOS2. The first tuple is the bounded output.\n", - "\n", - "See the [inequality bounds notebook](piecewise-inequality-bounds-tutorial) for mismatched curvature, auto-dispatch fallbacks, and more geometry." - ] + "source": "## 4. Inequality bounds — per-tuple sign\n\nAppend a third tuple element (`\"<=\"` or `\">=\"`) to mark a single expression as **bounded** by the piecewise curve instead of pinned to it. The other tuples stay on the curve. The 2-variable hypograph (`y ≤ f(x)`) and epigraph (`y ≥ f(x)`) are the canonical cases.\n\nOn a concave curve with `<=` (or convex with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation — no binaries, no SOS2.\n\nAt most one tuple may carry a non-equality sign. With 3 or more tuples, all signs must be `\"==\"`; the multi-input bounded case is reserved for a future bivariate / triangulated piecewise API.\n\nSee the [inequality bounds notebook](piecewise-inequality-bounds-tutorial) for mismatched curvature, auto-dispatch fallbacks, and more geometry." }, { "cell_type": "code", @@ -209,68 +198,7 @@ } }, "outputs": [], - "source": [ - "m = linopy.Model()\n", - "power = m.add_variables(name=\"power\", lower=0, upper=120, coords=[time])\n", - "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", - "\n", - "# concave curve: diminishing marginal fuel per MW\n", - "pwf = m.add_piecewise_formulation(\n", - " (fuel, [0, 50, 90, 120]), # bounded output (listed FIRST)\n", - " (power, [0, 40, 80, 120]),\n", - " sign=\"<=\",\n", - ")\n", - "m.add_constraints(power == xr.DataArray([30, 80, 100], coords=[time]))\n", - "m.add_objective(-fuel.sum()) # push fuel against the bound\n", - "m.solve(reformulate_sos=\"auto\")\n", - "\n", - "print(f\"resolved method={pwf.method}, curvature={pwf.convexity}\")\n", - "m.solution[[\"power\", \"fuel\"]].to_pandas()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**3-variable case — CHP plant with heat rejection**\n", - "\n", - "A CHP plant is characterised by a 1-parameter family of operating points (the load parameter). Power, fuel and heat are joint outputs of that parameter, tracing the characteristic curve simultaneously.\n", - "\n", - "For the inequality formulation to be physically meaningful, the first (bounded) tuple must correspond to a quantity with an available dissipation mechanism. A canonical example is **heat rejection** (also called *thermal curtailment*): when downstream heating demand falls below the plant's co-generation capacity at its committed electrical output, excess thermal output is rejected via a cooling tower. Electrical output and fuel draw remain pinned to the load parameter; heat delivery can be anywhere from the rejection floor up to the characteristic curve.\n", - "\n", - "Other admissible choices for the bounded tuple: electrical curtailment, emissions after post-treatment. Placing a consumption-side variable (such as fuel intake) in the bounded position yields a valid but *loose* formulation — safe only when no objective rewards driving it below the curve.\n", - "\n", - "Inequality formulations can also be faster to solve than the equality variant (see *Choice of bounded tuple* in the reference page), so the speed-vs-tightness trade-off is worth weighing even when the physics is strictly equality.\n", - "\n", - "Below: `heat` is the bounded output (rejection); `power` and `fuel` are pinned to the characteristic curve." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "m = linopy.Model()\n", - "power = m.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", - "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", - "heat = m.add_variables(name=\"heat\", lower=0, coords=[time])\n", - "\n", - "# bounded output listed FIRST (heat rejection); power, fuel pinned to the curve\n", - "m.add_piecewise_formulation(\n", - " (heat, [0, 25, 55, 95]), # bounded above — heat rejection\n", - " (power, [0, 30, 60, 100]), # pinned — electrical output at load\n", - " (fuel, [0, 40, 85, 160]), # pinned — fuel draw at load\n", - " sign=\"<=\",\n", - " method=\"sos2\",\n", - ")\n", - "# fix the load via a power target — remaining outputs are determined\n", - "m.add_constraints(power == xr.DataArray([30, 60, 100], coords=[time]))\n", - "m.add_objective(-heat.sum()) # maximise heat — no rejection required\n", - "m.solve(reformulate_sos=\"auto\")\n", - "\n", - "m.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas()" - ] + "source": "m = linopy.Model()\npower = m.add_variables(name=\"power\", lower=0, upper=120, coords=[time])\nfuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n\n# concave curve: diminishing marginal fuel per MW\npwf = m.add_piecewise_formulation(\n (fuel, [0, 50, 90, 120], \"<=\"), # bounded above by the curve\n (power, [0, 40, 80, 120]), # pinned to the curve\n)\nm.add_constraints(power == xr.DataArray([30, 80, 100], coords=[time]))\nm.add_objective(-fuel.sum()) # push fuel against the bound\nm.solve(reformulate_sos=\"auto\")\n\nprint(f\"resolved method={pwf.method}, curvature={pwf.convexity}\")\nm.solution[[\"power\", \"fuel\"]].to_pandas()" }, { "cell_type": "markdown", diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 313f7a0a..b29fa007 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -481,8 +481,8 @@ def _tangent_lines_impl( """ Chord-expression math — the body of ``tangent_lines`` without the :class:`EvolvingAPIWarning`. Called internally by ``_add_lp`` so a - single ``add_piecewise_formulation(sign="<=")`` emits exactly one - warning, not two. + single ``add_piecewise_formulation((y, y_pts, "<="), (x, x_pts))`` + emits exactly one warning, not two. """ from linopy.expressions import LinearExpression as LinExpr from linopy.variables import Variable @@ -523,12 +523,13 @@ def tangent_lines( is the chord of one segment: :math:`m_k \cdot x + c_k`. No auxiliary variables are created. - For most users: prefer :func:`add_piecewise_formulation` with - ``sign="<="`` / ``">="`` — it builds on this helper and adds the - ``x ∈ [x_min, x_max]`` domain bound plus a curvature-vs-sign check - that catches the "wrong region" case. Use ``tangent_lines`` directly - only when you need to compose the chord expressions manually (e.g. with - other linear terms, or without the domain bound). + For most users: prefer :func:`add_piecewise_formulation` with a + bounded tuple ``(y, y_pts, "<=")`` / ``(y, y_pts, ">=")`` — it builds + on this helper and adds the ``x ∈ [x_min, x_max]`` domain bound plus + a curvature-vs-sign check that catches the "wrong region" case. Use + ``tangent_lines`` directly only when you need to compose the chord + expressions manually (e.g. with other linear terms, or without the + domain bound). .. code-block:: python @@ -737,28 +738,30 @@ def _broadcast_points( def add_piecewise_formulation( model: Model, - *pairs: tuple[LinExprLike, BreaksLike], - sign: Literal["==", "<=", ">="] = "==", + *pairs: tuple[LinExprLike, BreaksLike] + | tuple[LinExprLike, BreaksLike, Literal["==", "<=", ">="]], method: Literal["sos2", "incremental", "lp", "auto"] = "auto", active: LinExprLike | None = None, name: str | None = None, + **kwargs: object, ) -> PiecewiseFormulation: r""" Add piecewise linear constraints. - 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. + Each positional argument is a ``(expression, breakpoints)`` tuple, or + ``(expression, breakpoints, sign)`` to mark that expression as bounded + by the piecewise curve rather than pinned to it. All expressions are + linked through shared interpolation weights so that every operating + point lies on the same segment of the piecewise curve. - Example — 2 variables:: + Example — 2 variables (joint equality, the default):: m.add_piecewise_formulation( (power, [0, 30, 60, 100]), (fuel, [0, 36, 84, 170]), ) - Example — 3 variables (CHP plant):: + Example — 3 variables, CHP plant (joint equality):: m.add_piecewise_formulation( (power, [0, 30, 60, 100]), @@ -766,45 +769,56 @@ def add_piecewise_formulation( (heat, [0, 25, 55, 95]), ) - **Sign — inequality bounds:** + **Per-tuple sign — inequality bounds:** - The ``sign`` parameter follows the *first-tuple convention*: + Add ``"<="`` or ``">="`` as a third tuple element to mark a single + expression as bounded by the curve instead of pinned to it. The + remaining tuples are still forced to equality (input on the curve). + Reads directly as the relation it encodes: - - ``sign="=="`` (default): all expressions must lie exactly on the - piecewise curve (joint equality). - - ``sign="<="``: the **first** tuple's expression is **bounded above** - by its interpolated value; all other tuples are forced to equality - (inputs on the curve). Reads as *"first expression ≤ f(the rest)"*. - - ``sign=">="``: same but the first is bounded **below**. + .. code-block:: python + + # fuel <= f(power) — concave curve, bounded above + m.add_piecewise_formulation( + (fuel, y_pts, "<="), + (power, x_pts), + ) + + # cost >= g(load) — convex curve, bounded below + m.add_piecewise_formulation( + (cost, y_pts, ">="), + (load, x_pts), + ) For 2-variable inequality on convex/concave curves, ``method="auto"`` automatically selects a pure-LP tangent-line formulation (no auxiliary variables). Non-convex curves fall back to SOS2/incremental with the - sign applied to the first tuple's link constraint. + sign applied to the bounded tuple's link constraint. - Example — ``fuel ≤ f(power)`` on a concave curve:: + **Restrictions on per-tuple sign:** - m.add_piecewise_formulation( - (fuel, y_pts), # bounded output, listed first - (power, x_pts), # input, always equality - sign="<=", - ) + - At most one tuple may carry a non-equality sign. All other tuples + default to ``"=="``. + - With **3 or more** tuples, all signs must be ``"=="``. + + Multi-bounded and N≥3-inequality use cases aren't supported yet. If + you have a concrete use case, please open an issue at + https://github.com/PyPSA/linopy/issues so we can scope it properly. Parameters ---------- - *pairs : tuple of (expression, breakpoints) + *pairs : tuple of (expression, breakpoints) or (expression, breakpoints, sign) Each pair links an expression (Variable or LinearExpression) to - its breakpoint values. At least two pairs are required. With - ``sign != EQUAL`` the **first** pair is the bounded output; all - later pairs are treated as inputs forced to equality. - sign : {"==", "<=", ">="}, default "==" - Constraint sign applied to the *first* tuple's link constraint. - Later tuples always use equality. See description above. + its breakpoint values. An optional third element ``"<="`` or + ``">="`` marks that expression as bounded by the curve; if + omitted, the expression is pinned (``"=="``). At least two pairs + are required; at most one may carry a non-equality sign; with + 3+ pairs all signs must be ``"=="``. method : {"auto", "sos2", "incremental", "lp"}, default "auto" Formulation method. ``"lp"`` uses tangent lines (pure LP, no variables) and requires - ``sign != EQUAL`` plus a matching-convexity curve with exactly - two tuples. + exactly one tuple with ``"<="`` or ``">="`` plus a matching-curvature + curve with exactly two tuples. ``"auto"`` picks ``"lp"`` when applicable, otherwise ``"incremental"`` (monotonic breakpoints) or ``"sos2"``. active : Variable or LinearExpression, optional @@ -812,9 +826,9 @@ def add_piecewise_formulation( ``active=0``, all auxiliary variables are forced to zero. Not supported with ``method="lp"``. - With ``sign="=="`` (the default), the output is then pinned to - ``0``. With ``sign="<="`` / ``">="``, deactivation only pushes - the signed bound to ``0`` (the output is ≤ 0 or ≥ 0 + With all-equality tuples (the default), the output is then pinned + to ``0``. With a bounded tuple (``"<="`` / ``">="``), deactivation + only pushes the signed bound to ``0`` (the output is ≤ 0 or ≥ 0 respectively) — the complementary bound still comes from the output variable's own lower/upper. In the common case where the output is naturally non-negative (fuel, cost, heat, …), @@ -834,14 +848,14 @@ def add_piecewise_formulation( ----- EvolvingAPIWarning ``add_piecewise_formulation`` is a newly-added API; details such - as the ``sign``/first-tuple convention and ``active`` + non-equality + as the per-tuple sign convention and ``active`` + non-equality sign semantics may be refined based on user feedback. Silence with ``warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)``. """ warnings.warn( "piecewise: add_piecewise_formulation is a new API; some details " - "(e.g. the sign/first-tuple convention, active+sign semantics) " + "(e.g. the per-tuple sign convention, active+sign semantics) " "may be refined in minor releases. Please share your use cases " "or concerns at https://github.com/PyPSA/linopy/issues — your " "feedback shapes what stabilises. Silence with " @@ -850,82 +864,142 @@ def add_piecewise_formulation( stacklevel=2, ) - # Normalize sign (accept "==" or "=" for equality, etc.). The Literal - # annotation above covers the user-facing forms; after normalization - # ``sign`` holds one of the canonical values in :data:`SIGNS`. - sign = sign_replace_dict.get(sign, sign) # type: ignore[assignment] - if sign not in SIGNS: - raise ValueError(f"sign must be one of {sorted(SIGNS)}, got '{sign}'") + # Migration helper: explicit error for the removed sign= keyword. + if "sign" in kwargs: + raise TypeError( + "The `sign=` keyword has been removed from add_piecewise_formulation. " + "Specify the sign per-tuple as a third tuple element, e.g. " + "`(fuel, y_pts, '<=')` instead of `sign='<='`. " + "See doc/piecewise-linear-constraints.rst." + ) + if kwargs: + raise TypeError( + "add_piecewise_formulation() got unexpected keyword argument(s): " + f"{sorted(kwargs)}" + ) + if method not in PWL_METHODS: raise ValueError(f"method must be one of {sorted(PWL_METHODS)}, got '{method}'") - if method == "lp" and sign == EQUAL: - raise ValueError("method='lp' requires sign='<=' or '>='.") if len(pairs) < 2: raise TypeError( "add_piecewise_formulation() requires at least 2 " - "(expression, breakpoints) pairs." + "(expression, breakpoints[, sign]) pairs." ) + # Parse and normalise per-tuple signs. Each pair is either + # (expr, bp) — sign defaults to "==" — or (expr, bp, sign). + parsed: list[tuple[LinExprLike, BreaksLike, str]] = [] for i, pair in enumerate(pairs): - if not isinstance(pair, tuple) or len(pair) != 2: + if not isinstance(pair, tuple) or len(pair) not in (2, 3): raise TypeError( - f"Argument {i + 1} must be a (expression, breakpoints) tuple, " - f"got {type(pair)}." + f"Argument {i + 1} must be a (expression, breakpoints) " + f"or (expression, breakpoints, sign) tuple, got {pair!r}." ) + if len(pair) == 2: + expr, bp = pair + tuple_sign: str = EQUAL + else: + expr, bp, raw_sign = pair + tuple_sign = sign_replace_dict.get(raw_sign, raw_sign) + if tuple_sign not in SIGNS: + raise ValueError( + f"Argument {i + 1}: sign must be one of " + f"{sorted(SIGNS)}, got {raw_sign!r}." + ) + parsed.append((expr, bp, tuple_sign)) + + # At most one non-equality sign; with 3+ tuples, none. + bounded_positions = [i for i, p in enumerate(parsed) if p[2] != EQUAL] + if len(bounded_positions) > 1: + raise ValueError( + "At most one tuple may carry a non-equality sign; got " + f"{len(bounded_positions)} (positions {bounded_positions})." + ) + if len(parsed) >= 3 and bounded_positions: + raise ValueError( + "Non-equality signs are not supported with 3+ tuples. " + "Use sign='==' on all tuples (the default), or reduce to 2 tuples. " + "If you have a concrete use case, please open an issue at " + "https://github.com/PyPSA/linopy/issues." + ) + + signed_idx: int | None + if bounded_positions: + bidx = bounded_positions[0] + signed_idx = bidx + sign: str = parsed[bidx][2] + else: + signed_idx = None + sign = EQUAL + + if method == "lp" and sign == EQUAL: + raise ValueError( + "method='lp' requires exactly one tuple with sign='<=' or '>='." + ) - # 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: + coerced_bps: list[DataArray] = [] + for _, bp, _s in parsed: 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 - first_bp = coerced[0][1] - disjunctive = SEGMENT_DIM in first_bp.dims + coerced_bps.append(bp) - # 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_breakpoint_shapes(first_bp, coerced[i][1]) + disjunctive = SEGMENT_DIM in coerced_bps[0].dims + for i in range(1, len(coerced_bps)): + _validate_breakpoint_shapes(coerced_bps[0], coerced_bps[i]) - # Broadcast all breakpoints to match all expression dimensions - all_exprs = [expr for expr, _ in coerced] + raw_exprs = [expr for expr, _, _ in parsed] bp_list = [ - _broadcast_points(bp, *all_exprs, disjunctive=disjunctive) for _, bp in coerced + _broadcast_points(bp, *raw_exprs, disjunctive=disjunctive) for bp in coerced_bps ] - # Compute combined mask from all breakpoints 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 - # Build link dimension coordinates from variable names from linopy.variables import Variable link_coords: list[str] = [] - for i, expr in enumerate(all_exprs): + for i, expr in enumerate(raw_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] + lin_exprs = [_to_linexpr(expr) for expr in raw_exprs] active_expr = _to_linexpr(active) if active is not None else None - # Snapshot existing names to detect what the formulation adds + if signed_idx is None: + inputs = _PwlInputs( + pinned_exprs=lin_exprs, + pinned_bps=bp_list, + pinned_coords=link_coords, + bounded_expr=None, + bounded_bp=None, + bounded_coord=None, + bounded_sign=EQUAL, + bp_mask=bp_mask, + ) + else: + inputs = _PwlInputs( + pinned_exprs=[e for j, e in enumerate(lin_exprs) if j != signed_idx], + pinned_bps=[b for j, b in enumerate(bp_list) if j != signed_idx], + pinned_coords=[c for j, c in enumerate(link_coords) if j != signed_idx], + bounded_expr=lin_exprs[signed_idx], + bounded_bp=bp_list[signed_idx], + bounded_coord=link_coords[signed_idx], + bounded_sign=sign, + bp_mask=bp_mask, + ) + vars_before = set(model.variables) cons_before = set(model.constraints) @@ -938,32 +1012,11 @@ def add_piecewise_formulation( raise ValueError( "method='lp' is not supported for disjunctive (segment) breakpoints" ) - _add_disjunctive( - model, - name, - lin_exprs, - bp_list, - link_coords, - bp_mask, - sign, - active_expr, - ) + _add_disjunctive(model, name, inputs, active_expr) resolved_method = "sos2" else: - # Continuous: stack into N-variable formulation - resolved_method = _add_continuous( - model, - name, - lin_exprs, - bp_list, - link_coords, - bp_mask, - method, - sign, - active_expr, - ) + resolved_method = _add_continuous(model, name, inputs, method, active_expr) - # Collect newly created variable and constraint names new_vars = [n for n in model.variables if n not in vars_before] new_cons = [n for n in model.constraints if n not in cons_before] @@ -974,15 +1027,19 @@ def add_piecewise_formulation( name, resolved_method, sign, - len(pairs), - "" if len(pairs) == 1 else "s", + inputs.n_tuples, + "" if inputs.n_tuples == 1 else "s", ) - # Compute convexity when well-defined: exactly two tuples (y, x), - # non-disjunctive, and strictly monotonic x breakpoints. convexity: Literal["convex", "concave", "linear", "mixed"] | None = None - if len(bp_list) == 2 and not disjunctive: - x_pts, y_pts = bp_list[1], bp_list[0] + if inputs.n_tuples == 2 and not disjunctive: + if inputs.is_equality: + x_pts = inputs.pinned_bps[1] + y_pts: DataArray = inputs.pinned_bps[0] + else: + assert inputs.bounded_bp is not None + x_pts = inputs.pinned_bps[0] + y_pts = inputs.bounded_bp if _check_strict_monotonicity(x_pts): convexity = _detect_convexity(x_pts, y_pts) @@ -1010,30 +1067,74 @@ def _stack_along_link( return xr.concat(expanded, dim=link_dim, coords="minimal") # type: ignore +@dataclass +class _PwlInputs: + """ + Categorised piecewise inputs (post-coercion, post-broadcast). + + ``pinned_*`` are the equality tuples in the user's original order. + ``bounded_*`` is the single non-equality tuple, or ``None``. + ``bounded_sign`` is ``EQUAL`` iff ``bounded_expr is None``. + """ + + pinned_exprs: list[LinearExpression] + pinned_bps: list[DataArray] + pinned_coords: list[str] + bounded_expr: LinearExpression | None + bounded_bp: DataArray | None + bounded_coord: str | None + bounded_sign: str + bp_mask: DataArray | None + link_dim: str = "_pwl_var" + + @property + def is_equality(self) -> bool: + return self.bounded_expr is None + + @property + def n_tuples(self) -> int: + return len(self.pinned_exprs) + (0 if self.is_equality else 1) + + def all_bps(self) -> list[DataArray]: + if self.bounded_bp is None: + return list(self.pinned_bps) + return [self.bounded_bp, *self.pinned_bps] + + def all_coords(self) -> list[str]: + if self.bounded_coord is None: + return list(self.pinned_coords) + return [self.bounded_coord, *self.pinned_coords] + + def all_exprs(self) -> list[LinearExpression]: + if self.bounded_expr is None: + return list(self.pinned_exprs) + return [self.bounded_expr, *self.pinned_exprs] + + def _lp_eligibility( - lin_exprs: list[LinearExpression], - bp_list: list[DataArray], - sign: str, + inputs: _PwlInputs, active: LinearExpression | None, ) -> tuple[bool, str]: """ Check whether LP tangent-lines dispatch is applicable. - Returns ``(True, "")`` if LP is applicable, else ``(False, reason)`` - with a short string describing why. Used for both auto-dispatch - and for an informational log when LP is skipped. + Returns ``(True, "")`` if LP is applicable, else ``(False, reason)``. """ - if len(lin_exprs) != 2: - return False, f"{len(lin_exprs)} expressions (LP supports only 2)" + if inputs.n_tuples != 2: + return False, f"{inputs.n_tuples} expressions (LP supports only 2)" + if inputs.is_equality: + return False, "all tuples are equality (LP needs one bounded tuple)" if active is not None: return False, "active=... is not supported by LP" - x_pts = bp_list[1] - y_pts = bp_list[0] + assert inputs.bounded_bp is not None # narrowed by is_equality check + x_pts = inputs.pinned_bps[0] + y_pts = inputs.bounded_bp if not _check_strict_monotonicity(x_pts): return False, "x breakpoints are not strictly monotonic" if not _has_trailing_nan_only(x_pts): return False, "x breakpoints contain non-trailing NaN" convexity = _detect_convexity(x_pts, y_pts) + sign = inputs.bounded_sign if sign == LESS_EQUAL and convexity not in ("concave", "linear"): return False, f"sign='<=' needs concave/linear curvature, got '{convexity}'" if sign == GREATER_EQUAL and convexity not in ("convex", "linear"): @@ -1044,14 +1145,7 @@ def _lp_eligibility( @dataclass class _PwlLinks: """ - Packaged link expressions for a SOS2/incremental/disjunctive builder. - - ``stacked_bp`` spans *all* tuples — used to size lambda/delta variables. - ``eq_expr`` / ``eq_bp`` form the equality link (stacks all tuples when - ``sign == "=="``, inputs-only otherwise; may be ``None`` if there are no - inputs on the equality side). - ``signed_expr`` / ``signed_bp`` are the first tuple's output-side link - (``None`` iff ``sign == "=="``). + Stacked link expressions consumed by SOS2/incremental/disjunctive builders. """ stacked_bp: DataArray @@ -1064,93 +1158,82 @@ class _PwlLinks: signed_bp: DataArray | None -def _build_links( - model: Model, - lin_exprs: list[LinearExpression], - bp_list: list[DataArray], - link_coords: list[str], - link_dim: str, - sign: str, - bp_mask: DataArray | None, -) -> _PwlLinks: - """ - Split (or stack) ``lin_exprs``/``bp_list`` into the equality and - signed link components dictated by ``sign``. - """ +def _build_links(model: Model, inputs: _PwlInputs) -> _PwlLinks: + """Stack ``inputs`` into the link representation.""" from linopy.expressions import LinearExpression - stacked_bp = _stack_along_link(bp_list, link_coords, link_dim) + stacked_bp = _stack_along_link( + inputs.all_bps(), inputs.all_coords(), inputs.link_dim + ) - if sign == EQUAL: - eq_data = _stack_along_link([e.data for e in lin_exprs], link_coords, link_dim) - # eq_bp is deliberately aliased to stacked_bp here — all tuples are - # already on the equality side, so the "full stack" and the "equality - # stack" are the same array. + if inputs.is_equality: + eq_data = _stack_along_link( + [e.data for e in inputs.pinned_exprs], + inputs.pinned_coords, + inputs.link_dim, + ) return _PwlLinks( stacked_bp=stacked_bp, - link_dim=link_dim, - bp_mask=bp_mask, - sign=sign, + link_dim=inputs.link_dim, + bp_mask=inputs.bp_mask, + sign=EQUAL, eq_expr=LinearExpression(eq_data, model), eq_bp=stacked_bp, signed_expr=None, signed_bp=None, ) - signed_expr = lin_exprs[0] - signed_bp = bp_list[0] - inputs_exprs = lin_exprs[1:] - inputs_bp = bp_list[1:] - inputs_coords = link_coords[1:] - if inputs_exprs: + if inputs.pinned_exprs: eq_data = _stack_along_link( - [e.data for e in inputs_exprs], inputs_coords, link_dim + [e.data for e in inputs.pinned_exprs], + inputs.pinned_coords, + inputs.link_dim, ) eq_expr: LinearExpression | None = LinearExpression(eq_data, model) - eq_bp: DataArray | None = _stack_along_link(inputs_bp, inputs_coords, link_dim) + eq_bp: DataArray | None = _stack_along_link( + inputs.pinned_bps, inputs.pinned_coords, inputs.link_dim + ) else: eq_expr = None eq_bp = None return _PwlLinks( stacked_bp=stacked_bp, - link_dim=link_dim, - bp_mask=bp_mask, - sign=sign, + link_dim=inputs.link_dim, + bp_mask=inputs.bp_mask, + sign=inputs.bounded_sign, eq_expr=eq_expr, eq_bp=eq_bp, - signed_expr=signed_expr, - signed_bp=signed_bp, + signed_expr=inputs.bounded_expr, + signed_bp=inputs.bounded_bp, ) def _try_lp( model: Model, name: str, - lin_exprs: list[LinearExpression], - bp_list: list[DataArray], + inputs: _PwlInputs, method: str, - sign: str, active: LinearExpression | None, ) -> bool: - """ - Dispatch the LP formulation if requested/eligible. - - Returns ``True`` when LP was built (caller should return ``"lp"``), - ``False`` when the caller should fall through to SOS2/incremental. - Raises on explicit ``method="lp"`` with mismatched inputs. - """ + """Dispatch the LP formulation if requested or eligible.""" if method == "lp": - if len(lin_exprs) != 2: + if inputs.n_tuples != 2: raise ValueError( - "method='lp' requires exactly 2 (expression, breakpoints) pairs." + "method='lp' requires exactly 2 (expression, breakpoints[, sign]) pairs." ) + if inputs.is_equality: + raise ValueError("method='lp' requires one tuple with sign='<=' or '>='.") if active is not None: raise ValueError("method='lp' is not compatible with active=...") - y_pts, x_pts = bp_list[0], bp_list[1] + assert inputs.bounded_bp is not None # narrowed by is_equality check + assert inputs.bounded_expr is not None + x_pts = inputs.pinned_bps[0] + y_pts = inputs.bounded_bp if not _check_strict_monotonicity(x_pts): raise ValueError("method='lp' requires strictly monotonic x breakpoints.") convexity = _detect_convexity(x_pts, y_pts) + sign = inputs.bounded_sign if sign == LESS_EQUAL and convexity not in ("concave", "linear"): raise ValueError( "method='lp' with sign='<=' requires concave or linear " @@ -1161,20 +1244,24 @@ def _try_lp( "method='lp' with sign='>=' requires convex or linear " f"curvature; got '{convexity}'. Use method='auto'." ) - _add_lp(model, name, lin_exprs[1], lin_exprs[0], x_pts, y_pts, sign) + _add_lp( + model, name, inputs.pinned_exprs[0], inputs.bounded_expr, x_pts, y_pts, sign + ) return True - if method == "auto" and sign != EQUAL: - ok, reason = _lp_eligibility(lin_exprs, bp_list, sign, active) + if method == "auto" and not inputs.is_equality: + ok, reason = _lp_eligibility(inputs, active) if ok: + assert inputs.bounded_expr is not None + assert inputs.bounded_bp is not None _add_lp( model, name, - lin_exprs[1], - lin_exprs[0], - bp_list[1], - bp_list[0], - sign, + inputs.pinned_exprs[0], + inputs.bounded_expr, + inputs.pinned_bps[0], + inputs.bounded_bp, + inputs.bounded_sign, ) return True logger.info( @@ -1222,27 +1309,15 @@ def _resolve_sos2_vs_incremental(method: str, stacked_bp: DataArray) -> str: def _add_continuous( model: Model, name: str, - lin_exprs: list[LinearExpression], - bp_list: list[DataArray], - link_coords: list[str], - bp_mask: DataArray | None, + inputs: _PwlInputs, method: str, - sign: str, active: LinearExpression | None = None, ) -> str: - """ - Dispatch continuous piecewise constraints. - - Returns the resolved method name ("lp", "sos2", or "incremental"). - """ - link_dim = "_pwl_var" - - if _try_lp(model, name, lin_exprs, bp_list, method, sign, active): + """Returns the resolved method name (``"lp"``, ``"sos2"``, ``"incremental"``).""" + if _try_lp(model, name, inputs, method, active): return "lp" - links = _build_links( - model, lin_exprs, bp_list, link_coords, link_dim, sign, bp_mask - ) + links = _build_links(model, inputs) method = _resolve_sos2_vs_incremental(method, links.stacked_bp) if method == "sos2": @@ -1387,23 +1462,14 @@ def _incremental_weighted(bp: DataArray) -> LinearExpression: def _add_disjunctive( model: Model, name: str, - lin_exprs: list[LinearExpression], - bp_list: list[DataArray], - link_coords: list[str], - bp_mask: DataArray | None, - sign: str, + inputs: _PwlInputs, active: LinearExpression | None = None, ) -> None: - """ - Disjunctive SOS2 formulation. Uses the shared ``_build_links`` - split: equality on inputs (all tuples when sign='=='), signed link - on the first tuple when sign != '=='. - """ - link_dim = "_pwl_var" - links = _build_links( - model, lin_exprs, bp_list, link_coords, link_dim, sign, bp_mask - ) + """Disjunctive SOS2 formulation.""" + link_dim = inputs.link_dim + links = _build_links(model, inputs) stacked_bp = links.stacked_bp + bp_mask = inputs.bp_mask _validate_numeric_breakpoint_coords(stacked_bp) if not _has_trailing_nan_only(stacked_bp): diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 7224a94a..0e2b8305 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -616,9 +616,8 @@ def test_sign_le_respected_by_solver(self) -> None: y = m.add_variables(lower=0, upper=40, name="y") # Two segments forming a concave profile: (0,0)→(10,20), (10,20)→(20,30) m.add_piecewise_formulation( - (y, segments([[0.0, 20.0], [20.0, 30.0]])), + (y, segments([[0.0, 20.0], [20.0, 30.0]]), "<="), (x, segments([[0.0, 10.0], [10.0, 20.0]])), - sign="<=", ) m.add_constraints(x == 15) m.add_objective(-y) # maximise y @@ -656,9 +655,8 @@ def test_sign_le_hits_correct_segment( x = m.add_variables(lower=0, upper=30, name="x") y = m.add_variables(lower=0, upper=50, name="y") m.add_piecewise_formulation( - (y, segments([[0.0, 10.0], [20.0, 35.0]])), # two slopes: 2 and 1.5 + (y, segments([[0.0, 10.0], [20.0, 35.0]]), "<="), # two slopes: 2 and 1.5 (x, segments([[0.0, 5.0], [15.0, 25.0]])), - sign="<=", ) m.add_constraints(x == x_fix) m.add_objective(-y) @@ -672,9 +670,8 @@ def test_sign_le_in_forbidden_zone_infeasible(self) -> None: x = m.add_variables(lower=0, upper=30, name="x") y = m.add_variables(lower=0, upper=50, name="y") m.add_piecewise_formulation( - (y, segments([[0.0, 10.0], [20.0, 35.0]])), + (y, segments([[0.0, 10.0], [20.0, 35.0]]), "<="), (x, segments([[0.0, 5.0], [15.0, 25.0]])), - sign="<=", ) m.add_constraints(x == 10.0) # in the gap (5, 15) m.add_objective(-y) @@ -1463,7 +1460,7 @@ def test_scalar_coord_dropped(self) -> None: class TestSignParameter: - """Tests for sign="<=" / ">=" with the first-tuple convention.""" + """Tests for per-tuple sign on add_piecewise_formulation.""" def test_default_is_equality(self) -> None: m = Model() @@ -1473,12 +1470,50 @@ def test_default_is_equality(self) -> None: # no output_link for equality — single stacked link only assert f"pwl0{PWL_OUTPUT_LINK_SUFFIX}" not in m.constraints - def test_invalid_sign_raises(self) -> None: + def test_invalid_per_tuple_sign_raises(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") with pytest.raises(ValueError, match="sign must be"): - m.add_piecewise_formulation((x, [0, 10]), (y, [0, 5]), sign="!") # type: ignore + m.add_piecewise_formulation((x, [0, 10], "!"), (y, [0, 5])) # type: ignore + + def test_old_sign_kwarg_raises_with_migration_help(self) -> None: + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + with pytest.raises(TypeError, match="sign=.*has been removed"): + m.add_piecewise_formulation((x, [0, 10]), (y, [0, 5]), sign="<=") # type: ignore[call-arg] + + def test_two_bounded_tuples_raises(self) -> None: + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + with pytest.raises(ValueError, match="At most one tuple"): + m.add_piecewise_formulation((x, [0, 10], "<="), (y, [0, 5], ">=")) + + def test_three_tuples_with_inequality_raises(self) -> None: + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + z = m.add_variables(name="z") + with pytest.raises(ValueError, match="3\\+ tuples"): + m.add_piecewise_formulation( + (x, [0, 10], "<="), + (y, [0, 5]), + (z, [0, 1]), + ) + + def test_bounded_tuple_in_second_position(self) -> None: + """User's tuple order is preserved — bounded tuple need not be first.""" + m = Model() + x = m.add_variables(lower=0, upper=30, name="x") + y = m.add_variables(lower=0, upper=40, name="y") + f = m.add_piecewise_formulation( + (x, [0, 10, 20, 30]), + (y, [0, 20, 30, 35], "<="), + ) + # LP fast-path still triggers regardless of tuple position + assert f.method == "lp" def test_lp_with_equality_raises(self) -> None: m = Model() @@ -1494,9 +1529,8 @@ def test_auto_picks_lp_for_concave_le(self) -> None: fuel = m.add_variables(lower=0, upper=40, name="fuel") # Concave: slopes 2, 1, 0.5 m.add_piecewise_formulation( - (fuel, [0, 20, 30, 35]), + (fuel, [0, 20, 30, 35], "<="), (power, [0, 10, 20, 30]), - sign="<=", ) assert f"pwl0{PWL_CHORD_SUFFIX}" in m.constraints assert f"pwl0{PWL_DOMAIN_LO_SUFFIX}" in m.constraints @@ -1511,9 +1545,8 @@ def test_auto_picks_lp_for_convex_ge(self) -> None: y = m.add_variables(lower=0, upper=100, name="y") # Convex: slopes 1, 2, 3 m.add_piecewise_formulation( - (y, [0, 10, 30, 60]), + (y, [0, 10, 30, 60], ">="), (x, [0, 10, 20, 30]), - sign=">=", ) assert f"pwl0{PWL_CHORD_SUFFIX}" in m.constraints @@ -1524,9 +1557,8 @@ def test_auto_falls_back_to_sos2_for_nonmonotonic(self) -> None: y = m.add_variables(name="y") # Non-monotonic x m.add_piecewise_formulation( - (y, [0, 5, 2, 20]), + (y, [0, 5, 2, 20], "<="), (x, [0, 10, 5, 50]), - sign="<=", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables assert f"pwl0{PWL_OUTPUT_LINK_SUFFIX}" in m.constraints @@ -1537,9 +1569,8 @@ def test_auto_concave_ge_falls_back_from_lp(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") f = m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), # concave + (y, [0, 20, 30, 35], ">="), # concave (x, [0, 10, 20, 30]), - sign=">=", ) assert f.method != "lp" # fallback (sos2 or incremental) @@ -1549,9 +1580,8 @@ def test_auto_convex_le_falls_back_from_lp(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") f = m.add_piecewise_formulation( - (y, [0, 10, 30, 60]), # convex + (y, [0, 10, 30, 60], "<="), # convex (x, [0, 10, 20, 30]), - sign="<=", ) assert f.method != "lp" @@ -1562,9 +1592,8 @@ def test_lp_concave_ge_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="convex"): m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), # concave + (y, [0, 20, 30, 35], ">="), # concave (x, [0, 10, 20, 30]), - sign=">=", method="lp", ) @@ -1576,9 +1605,8 @@ def test_lp_nonmatching_convexity_raises(self) -> None: # Convex curve, sign='<=' mismatch with pytest.raises(ValueError, match="concave"): m.add_piecewise_formulation( - (y, [0, 10, 30, 60]), # convex + (y, [0, 10, 30, 60], "<="), # convex (x, [0, 10, 20, 30]), - sign="<=", method="lp", ) @@ -1588,9 +1616,8 @@ def test_sos2_sign_le_has_output_link(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), + (y, [0, 20, 30, 35], "<="), (x, [0, 10, 20, 30]), - sign="<=", method="sos2", ) link = m.constraints[f"pwl0{PWL_OUTPUT_LINK_SUFFIX}"] @@ -1602,35 +1629,14 @@ def test_incremental_sign_le(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), + (y, [0, 20, 30, 35], "<="), (x, [0, 10, 20, 30]), - sign="<=", method="incremental", ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables link = m.constraints[f"pwl0{PWL_OUTPUT_LINK_SUFFIX}"] assert (link.sign == "<=").all().item() - def test_nvar_inequality_bounds_first_tuple(self) -> None: - """N-variable: first tuple is bounded, others on curve.""" - m = Model() - fuel = m.add_variables(name="fuel") - power = m.add_variables(name="power") - heat = m.add_variables(name="heat") - m.add_piecewise_formulation( - (fuel, [0, 40, 85, 160]), # bounded - (power, [0, 30, 60, 100]), # input == - (heat, [0, 25, 55, 95]), # input == - sign="<=", - method="sos2", - ) - # inputs stacked, output signed - link = m.constraints[f"pwl0{PWL_LINK_SUFFIX}"] - output_link = m.constraints[f"pwl0{PWL_OUTPUT_LINK_SUFFIX}"] - assert "_pwl_var" in link.labels.dims # stacked inputs - assert "_pwl_var" not in output_link.labels.dims # single output - assert (output_link.sign == "<=").all().item() - def test_lp_consistency_with_sos2(self) -> None: """LP and SOS2 give the same fuel at a fixed power (within domain).""" x_pts = [0, 10, 20, 30] @@ -1643,9 +1649,8 @@ def test_lp_consistency_with_sos2(self) -> None: power = m.add_variables(lower=0, upper=30, name="power") fuel = m.add_variables(lower=0, upper=40, name="fuel") m.add_piecewise_formulation( - (fuel, y_pts), + (fuel, y_pts, "<="), (power, x_pts), - sign="<=", method=method, ) m.add_constraints(power == 15) @@ -1663,17 +1668,15 @@ def test_convexity_invariant_to_x_direction(self) -> None: xa = m_asc.add_variables(name="x") ya = m_asc.add_variables(name="y") f_asc = m_asc.add_piecewise_formulation( - (ya, [0, 20, 30, 35]), + (ya, [0, 20, 30, 35], ">="), (xa, [0, 10, 20, 30]), - sign=">=", ) m_desc = Model() xd = m_desc.add_variables(name="x") yd = m_desc.add_variables(name="y") f_desc = m_desc.add_piecewise_formulation( - (yd, [35, 30, 20, 0]), + (yd, [35, 30, 20, 0], ">="), (xd, [30, 20, 10, 0]), - sign=">=", ) assert f_asc.convexity == f_desc.convexity == "concave" # concave + >= must fall back from LP @@ -1698,9 +1701,8 @@ def test_lp_per_entity_nan_padding(self) -> None: x = m.add_variables(lower=0, upper=20, coords=[coord], name="x") y = m.add_variables(lower=0, upper=40, coords=[coord], name="y") m.add_piecewise_formulation( - (y, breakpoints(bp_y, dim="entity")), + (y, breakpoints(bp_y, dim="entity"), "<="), (x, breakpoints(bp_x, dim="entity")), - sign="<=", method=method, ) m.add_constraints(x.sel(entity="b") == 10) @@ -1721,9 +1723,8 @@ def test_lp_rejects_decreasing_x_concave_ge(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="convex"): m.add_piecewise_formulation( - (y, [35, 30, 20, 0]), # same concave curve + (y, [35, 30, 20, 0], ">="), # same concave curve (x, [30, 20, 10, 0]), # decreasing x - sign=">=", method="lp", ) @@ -1742,9 +1743,8 @@ def test_active_off_with_sign_le_leaves_lower_open(self, method: Method) -> None y = m.add_variables(lower=-100, upper=100, name="y") active = m.add_variables(binary=True, name="active") m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), + (y, [0, 20, 30, 35], "<="), (x, [0, 10, 20, 30]), - sign="<=", method=method, active=active, ) @@ -1769,9 +1769,8 @@ def test_active_off_with_sign_le_and_lower_zero_pins_output(self) -> None: y = m.add_variables(lower=0, upper=100, name="y") # the recipe active = m.add_variables(binary=True, name="active") m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), + (y, [0, 20, 30, 35], "<="), (x, [0, 10, 20, 30]), - sign="<=", method="sos2", active=active, ) @@ -1788,9 +1787,8 @@ def test_active_off_with_sign_le_disjunctive(self) -> None: y = m.add_variables(lower=-100, upper=100, name="y") active = m.add_variables(binary=True, name="active") m.add_piecewise_formulation( - (y, segments([[0.0, 20.0], [20.0, 35.0]])), + (y, segments([[0.0, 20.0], [20.0, 35.0]]), "<="), (x, segments([[0.0, 10.0], [10.0, 30.0]])), - sign="<=", active=active, ) m.add_constraints(active == 0) @@ -1810,9 +1808,8 @@ def test_lp_active_explicit_raises(self) -> None: u = m.add_variables(binary=True, name="u") with pytest.raises(ValueError, match="active"): m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), + (y, [0, 20, 30, 35], "<="), (x, [0, 10, 20, 30]), - sign="<=", method="lp", active=u, ) @@ -1828,9 +1825,8 @@ def test_lp_accepts_linear_curve(self) -> None: x = m.add_variables(lower=0, upper=30, name="x") y = m.add_variables(lower=0, upper=60, name="y") f = m.add_piecewise_formulation( - (y, [0, 10, 20, 30]), # linear (all slopes = 1) + (y, [0, 10, 20, 30], sign), # linear (all slopes = 1) (x, [0, 10, 20, 30]), - sign=sign, method="lp", ) assert f.method == "lp" @@ -1848,9 +1844,8 @@ def test_auto_logs_when_lp_is_skipped( y = m.add_variables(name="y") with caplog.at_level(logging.INFO, logger="linopy.piecewise"): m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), # concave + sign='>=' → LP skipped + (y, [0, 20, 30, 35], ">="), # concave + sign='>=' → LP skipped (x, [0, 10, 20, 30]), - sign=">=", ) assert "LP not applicable" in caplog.text @@ -1864,9 +1859,8 @@ def test_lp_domain_bound_infeasible_when_x_out_of_range(self) -> None: x = m.add_variables(lower=0, upper=100, name="x") y = m.add_variables(lower=0, upper=100, name="y") m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), + (y, [0, 20, 30, 35], "<="), (x, [0, 10, 20, 30]), # x_max = 30 - sign="<=", method="lp", ) m.add_constraints(x >= 50) @@ -1890,9 +1884,8 @@ def test_lp_matches_sos2_on_multi_dim_variables(self) -> None: x = m.add_variables(lower=0, upper=30, coords=[entities], name="x") y = m.add_variables(lower=0, upper=40, coords=[entities], name="y") m.add_piecewise_formulation( - (y, breakpoints(bp_y, dim="entity")), + (y, breakpoints(bp_y, dim="entity"), "<="), (x, breakpoints(bp_x, dim="entity")), - sign="<=", method=method, ) m.add_constraints(x.sel(entity="a") == 15) @@ -1920,9 +1913,7 @@ def test_lp_consistency_with_sos2_both_directions(self) -> None: m = Model() p = m.add_variables(lower=0, upper=30, name="p") f = m.add_variables(lower=0, upper=50, name="f") - m.add_piecewise_formulation( - (f, y_pts), (p, x_pts), sign="<=", method=method - ) + m.add_piecewise_formulation((f, y_pts, "<="), (p, x_pts), method=method) m.add_constraints(p == 15) m.add_objective(obj_sign * f) m.solve() diff --git a/test/test_piecewise_feasibility.py b/test/test_piecewise_feasibility.py index cedf6d40..ed5dd49b 100644 --- a/test/test_piecewise_feasibility.py +++ b/test/test_piecewise_feasibility.py @@ -37,7 +37,6 @@ Sign: TypeAlias = Literal["<=", ">="] Method: TypeAlias = Literal["lp", "sos2", "incremental"] -MethodND: TypeAlias = Literal["sos2", "incremental"] # LP doesn't support N > 2 TOL = 1e-5 X_LO, X_HI = -100.0, 100.0 @@ -113,9 +112,8 @@ def build_model(curve: Curve, method: Method) -> tuple[Model, Variable, Variable x = m.add_variables(lower=X_LO, upper=X_HI, name="x") y = m.add_variables(lower=Y_LO, upper=Y_HI, name="y") m.add_piecewise_formulation( - (y, list(curve.y_pts)), + (y, list(curve.y_pts), curve.sign), (x, list(curve.x_pts)), - sign=curve.sign, method=method, ) return m, x, y @@ -268,194 +266,6 @@ def test_just_past_curve(self, curve: Curve, method: Method) -> None: ) -# --------------------------------------------------------------------------- -# 3-variable inequality: sign='<=' splits bounded output from equality inputs -# --------------------------------------------------------------------------- - - -class TestNVariableInequality: - """ - 3-variable ``sign="<="``: the first tuple (output) is bounded above, - the remaining tuples (inputs) are pinned on the curve — equality-linked. - - LP does not support ``N > 2``, so this is SOS2 vs incremental only. - The feasible region is a "ribbon" along the fuel axis parameterised - by the curve's ``(power, heat)`` trajectory: - - { (fuel, power, heat) : ∃ λ SOS2 with Σλ=1, - power = Σλ·p_i, heat = Σλ·h_i, FUEL_LO ≤ fuel ≤ Σλ·f_i } - - Tests probe this region from several angles: a vertex-enumeration - oracle for rotated objectives, plus targeted feasible/infeasible - point checks. - """ - - BP = { - "power": (0, 30, 60, 100), - "fuel": (0, 40, 85, 160), # bounded output (first tuple) - "heat": (0, 25, 55, 95), # input, forced to equality - } - FUEL_LO, FUEL_HI = 0.0, 200.0 - POWER_LO, POWER_HI = 0.0, 100.0 - HEAT_LO, HEAT_HI = 0.0, 100.0 - - @pytest.fixture(params=["sos2", "incremental"]) - def method_3var(self, request: pytest.FixtureRequest) -> MethodND: - return request.param - - # ---- helpers -------------------------------------------------------- - - def _build(self, method: MethodND) -> tuple[Model, Variable, Variable, Variable]: - """CHP model with sign='<=': fuel bounded, power/heat equality-linked.""" - m = Model() - power = m.add_variables(lower=self.POWER_LO, upper=self.POWER_HI, name="power") - fuel = m.add_variables(lower=self.FUEL_LO, upper=self.FUEL_HI, name="fuel") - heat = m.add_variables(lower=self.HEAT_LO, upper=self.HEAT_HI, name="heat") - m.add_piecewise_formulation( - (fuel, list(self.BP["fuel"])), - (power, list(self.BP["power"])), - (heat, list(self.BP["heat"])), - sign="<=", - method=method, - ) - return m, fuel, power, heat - - def _oracle_support_3d( - self, alpha_f: float, alpha_p: float, alpha_h: float - ) -> float: - """ - Ground-truth ``min α_f·fuel + α_p·power + α_h·heat`` over the region. - - The region is a convex polytope with vertices at each breakpoint - in two "layers": the top ``(f_i, p_i, h_i)`` and the bottom - ``(FUEL_LO, p_i, h_i)`` — linear objective extrema are at vertices. - """ - fuels = self.BP["fuel"] - powers = self.BP["power"] - heats = self.BP["heat"] - top = [ - alpha_f * f + alpha_p * p + alpha_h * h - for f, p, h in zip(fuels, powers, heats) - ] - bot = [ - alpha_f * self.FUEL_LO + alpha_p * p + alpha_h * h - for p, h in zip(powers, heats) - ] - return min(top + bot) - - # ---- existing test: fuel pushed against its upper bound ------------- - - @pytest.mark.parametrize("power_fix", [0, 15, 30, 45, 60, 80, 100]) - def test_first_tuple_bounded_rest_equal( - self, method_3var: MethodND, power_fix: float - ) -> None: - m, fuel, power, heat = self._build(method_3var) - m.add_constraints(power == power_fix) - m.add_objective(-fuel) # push fuel against its bound - status, _ = m.solve() - assert status == "ok" - - expect_fuel = float(np.interp(power_fix, self.BP["power"], self.BP["fuel"])) - expect_heat = float(np.interp(power_fix, self.BP["power"], self.BP["heat"])) - - assert abs(float(m.solution["fuel"]) - expect_fuel) < TOL, ( - f"{method_3var}: fuel at power={power_fix} should hit " - f"f(x)={expect_fuel}, got {float(m.solution['fuel'])}" - ) - assert abs(float(m.solution["heat"]) - expect_heat) < TOL, ( - f"{method_3var}: heat at power={power_fix} must equal " - f"f(x)={expect_heat}, got {float(m.solution['heat'])}" - ) - - # ---- new: heat drifting off the curve is infeasible ----------------- - - @pytest.mark.parametrize("power_fix", [15, 45, 80]) - def test_heat_off_curve_is_infeasible( - self, method_3var: MethodND, power_fix: float - ) -> None: - """ - Heat is equality-linked. Pinning heat away from ``f_heat(power)`` - must make the model infeasible under both methods. - """ - expect_heat = float(np.interp(power_fix, self.BP["power"], self.BP["heat"])) - m, fuel, power, heat = self._build(method_3var) - m.add_constraints(power == power_fix) - m.add_constraints(heat == expect_heat + 5.0) # nudge off the curve - m.add_objective(fuel) - status, _ = m.solve() - assert status != "ok", ( - f"{method_3var}: heat={expect_heat + 5} at power={power_fix} " - f"should be infeasible (curve has heat={expect_heat})" - ) - - # ---- new: interior point is feasible -------------------------------- - - @pytest.mark.parametrize("power_fix", [15, 45, 80]) - def test_interior_point_is_feasible( - self, method_3var: MethodND, power_fix: float - ) -> None: - """ - With power/heat on the curve and fuel well below its upper - bound, the point is interior to the ribbon — must be feasible. - """ - expect_heat = float(np.interp(power_fix, self.BP["power"], self.BP["heat"])) - expect_fuel = float(np.interp(power_fix, self.BP["power"], self.BP["fuel"])) - m, fuel, power, heat = self._build(method_3var) - m.add_constraints(power == power_fix) - m.add_constraints(heat == expect_heat) - m.add_constraints(fuel == expect_fuel - 10.0) # below the bound - m.add_objective(fuel) - status, _ = m.solve() - assert status == "ok", ( - f"{method_3var}: interior point (power={power_fix}, " - f"heat={expect_heat}, fuel={expect_fuel - 10}) should be feasible" - ) - - # ---- new: rotated objective in 3D ----------------------------------- - - DIRECTIONS_3D = [ - pytest.param(-1.0, 0.0, 0.0, id="maxfuel"), - pytest.param(+1.0, 0.0, 0.0, id="minfuel"), - pytest.param(0.0, -1.0, 0.0, id="maxpower"), - pytest.param(0.0, +1.0, 0.0, id="minpower"), - pytest.param(0.0, 0.0, -1.0, id="maxheat"), - pytest.param(0.0, 0.0, +1.0, id="minheat"), - pytest.param(-1.0, -1.0, -1.0, id="maxall"), - pytest.param(+1.0, +1.0, +1.0, id="minall"), - ] - - @pytest.mark.parametrize("alpha_f, alpha_p, alpha_h", DIRECTIONS_3D) - def test_rotated_support_matches_oracle( - self, - method_3var: MethodND, - alpha_f: float, - alpha_p: float, - alpha_h: float, - ) -> None: - """ - Support function equivalence in 3-space: each method lands at - the same vertex as the vertex-enumeration oracle. - """ - m, fuel, power, heat = self._build(method_3var) - m.add_objective(alpha_f * fuel + alpha_p * power + alpha_h * heat) - status, _ = m.solve() - assert status == "ok", ( - f"{method_3var}: solve failed at ({alpha_f},{alpha_p},{alpha_h})" - ) - fs = float(m.solution["fuel"]) - ps = float(m.solution["power"]) - hs = float(m.solution["heat"]) - got = alpha_f * fs + alpha_p * ps + alpha_h * hs - want = self._oracle_support_3d(alpha_f, alpha_p, alpha_h) - assert abs(got - want) < TOL, ( - f"\n method: {method_3var}" - f"\n direction: (α_fuel={alpha_f:+}, α_power={alpha_p:+}, α_heat={alpha_h:+})" - f"\n attained: fuel={fs:+.6f}, power={ps:+.6f}, heat={hs:+.6f}" - f"\n attained obj: {got:+.6f} oracle obj: {want:+.6f}" - f"\n diff: {got - want:+.3e} (TOL={TOL:.1e})" - ) - - # --------------------------------------------------------------------------- # Hand-computed anchors — sanity-check the oracle itself # --------------------------------------------------------------------------- @@ -540,48 +350,3 @@ def test_convex_ge_at_midsegment(self, method: Method) -> None: m.add_objective(y) # minimise — pushes y against the lower bound (curve) m.solve() assert float(m.solution["y"]) == pytest.approx(2.5, abs=TOL) - - # ---- 3-variable CHP ------------------------------------------------ - - @pytest.mark.parametrize("method_3var", ["sos2", "incremental"]) - def test_chp_at_breakpoint(self, method_3var: MethodND) -> None: - """CHP at power=60 (exact breakpoint 2): max fuel=85, heat=55.""" - m = Model() - power = m.add_variables(lower=0, upper=100, name="power") - fuel = m.add_variables(lower=0, upper=200, name="fuel") - heat = m.add_variables(lower=0, upper=100, name="heat") - m.add_piecewise_formulation( - (fuel, [0, 40, 85, 160]), - (power, [0, 30, 60, 100]), - (heat, [0, 25, 55, 95]), - sign="<=", - method=method_3var, - ) - m.add_constraints(power == 60.0) - m.add_objective(-fuel) - m.solve() - assert float(m.solution["fuel"]) == pytest.approx(85.0, abs=TOL) - assert float(m.solution["heat"]) == pytest.approx(55.0, abs=TOL) - - @pytest.mark.parametrize("method_3var", ["sos2", "incremental"]) - def test_chp_at_midsegment(self, method_3var: MethodND) -> None: - """ - CHP at power=45 (midway between bp1=30 and bp2=60): - fuel = (40 + 85)/2 = 62.5, heat = (25 + 55)/2 = 40.0. - """ - m = Model() - power = m.add_variables(lower=0, upper=100, name="power") - fuel = m.add_variables(lower=0, upper=200, name="fuel") - heat = m.add_variables(lower=0, upper=100, name="heat") - m.add_piecewise_formulation( - (fuel, [0, 40, 85, 160]), - (power, [0, 30, 60, 100]), - (heat, [0, 25, 55, 95]), - sign="<=", - method=method_3var, - ) - m.add_constraints(power == 45.0) - m.add_objective(-fuel) - m.solve() - assert float(m.solution["fuel"]) == pytest.approx(62.5, abs=TOL) - assert float(m.solution["heat"]) == pytest.approx(40.0, abs=TOL) From ed22470670742d52e9c2313ffc100f59b0840b66 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:58:47 +0200 Subject: [PATCH 39/65] fix(piecewise): drop unused type: ignore on removed-sign kwarg test The function now accepts **kwargs to give a clear TypeError on the removed `sign=` keyword, so mypy doesn't flag the call site and the ignore is unused. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/test_piecewise_constraints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 0e2b8305..8031df63 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -1482,7 +1482,7 @@ def test_old_sign_kwarg_raises_with_migration_help(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") with pytest.raises(TypeError, match="sign=.*has been removed"): - m.add_piecewise_formulation((x, [0, 10]), (y, [0, 5]), sign="<=") # type: ignore[call-arg] + m.add_piecewise_formulation((x, [0, 10]), (y, [0, 5]), sign="<=") def test_two_bounded_tuples_raises(self) -> None: m = Model() From 83c5d21ffa26dcd159806056ffe1a34f5bd3000d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:13:11 +0200 Subject: [PATCH 40/65] docs(piecewise): reorder methods, disambiguate segments, point to runtime introspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reorder formulation sections LP → SOS2 → Incremental → Disjunctive (simple to complex) in both the comparison table and method subsections. - Disambiguate the breakpoints() vs segments() factories: connected curve vs disjoint operating regions consumed by the disjunctive formulation. - Replace the brittle "Generated variables and constraints" listing with a short "Inspecting generated objects" pointer to the returned PiecewiseFormulation's .variables / .constraints live views, since exact name suffixes are an implementation detail. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/piecewise-linear-constraints.rst | 163 ++++++++++++--------------- 1 file changed, 73 insertions(+), 90 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 17bb7b95..12b19b44 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -74,14 +74,20 @@ equality (all tuples on the curve, the default) or a one-sided bound ``breakpoints`` and ``segments`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Factory functions that create DataArrays with the correct dimension names: +Two factories with distinct geometric meaning: + +- ``breakpoints()`` — values along a single **connected** curve. Linear + pieces between adjacent breakpoints are interpolated continuously. +- ``segments()`` — **disjoint** operating regions with gaps between them + (e.g. forbidden zones). Builds a 2-D array consumed by the + *disjunctive* formulation, where exactly one region is active at a time. .. code-block:: python - linopy.breakpoints([0, 50, 100]) # list + linopy.breakpoints([0, 50, 100]) # connected 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([(0, 10), (50, 100)]) # two disjoint regions linopy.segments({"gen1": [(0, 10)], "gen2": [(0, 80)]}, dim="gen") @@ -304,9 +310,9 @@ At-a-glance comparison: :widths: 26 18 18 18 20 * - Property + - ``lp`` - ``sos2`` - ``incremental`` - - ``lp`` - Disjunctive * - Segment layout - Connected @@ -314,41 +320,85 @@ At-a-glance comparison: - Connected - Disconnected * - Supported per-tuple sign + - one ``<=`` or ``>=`` (required) - all ``==`` or one ``<=``/``>=`` - all ``==`` or one ``<=``/``>=`` - - one ``<=`` or ``>=`` (required) - all ``==`` or one ``<=``/``>=`` * - Number of tuples + - Exactly 2 - ≥ 2 (3+ requires all ``==``) - ≥ 2 (3+ requires all ``==``) - - Exactly 2 - ≥ 2 (3+ requires all ``==``) * - Breakpoint order - - Any - Strictly monotonic + - Any - Strictly monotonic - Any (per segment) * - Curvature requirement + - Concave (``<=``) or convex (``>=``) - None - None - - Concave (``<=``) or convex (``>=``) - None * - Auxiliary variables + - **None** - Continuous + SOS2 - Continuous + binary - - **None** - Binary + SOS2 * - ``active=`` supported + - No - Yes - Yes - - No - Yes * - Solver requirement + - **Any LP solver** - SOS2-capable - MIP-capable - - **Any LP solver** - SOS2 + MIP +LP (chord-line) Formulation +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For **2-variable inequality** on a **convex** or **concave** curve. Adds one +chord inequality per segment plus a domain bound — no auxiliary variables and +no MIP relaxation: + +.. math:: + + &y \ \text{sign}\ m_k \cdot x + c_k + \quad \forall\ \text{segments } k + + &x_0 \le x \le x_n + +where :math:`m_k = (y_{k+1} - y_k)/(x_{k+1} - x_k)` and +:math:`c_k = y_k - m_k\, x_k`. For concave :math:`f` with ``sign="<="``, +the intersection of all chord inequalities equals the hypograph of +:math:`f` on its domain. + +The LP dispatch requires curvature and sign to match: ``sign="<="`` needs +concave (or linear); ``sign=">="`` needs convex (or linear). A mismatch +is *not* just a loose bound — it describes the wrong region (see the +:doc:`piecewise-inequality-bounds-tutorial`). ``method="auto"`` detects +this and falls back; ``method="lp"`` raises. + +.. code-block:: python + + # y <= f(x) on a concave f — auto picks LP + m.add_piecewise_formulation((y, yp, "<="), (x, xp)) + + # Or explicitly: + m.add_piecewise_formulation((y, yp, "<="), (x, xp), method="lp") + +**Not supported with** ``method="lp"``: all-equality, more than 2 tuples, +and ``active``. ``method="auto"`` falls back to SOS2/incremental in all +three cases. + +The underlying chord expressions are also exposed as a standalone helper, +``linopy.tangent_lines(x, x_pts, y_pts)``, which returns the per-segment +chord as a :class:`~linopy.expressions.LinearExpression` with no variables +created. Use it directly if you want to compose the chord bound with other +constraints by hand, without the domain bound that ``method="lp"`` adds +automatically. + SOS2 (Convex Combination) ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -402,50 +452,6 @@ equality above; the bounded tuple's link uses the requested sign. **Limitation:** breakpoint sequences must be strictly monotonic. -LP (chord-line) Formulation -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -For **2-variable inequality** on a **convex** or **concave** curve. Adds one -chord inequality per segment plus a domain bound — no auxiliary variables and -no MIP relaxation: - -.. math:: - - &y \ \text{sign}\ m_k \cdot x + c_k - \quad \forall\ \text{segments } k - - &x_0 \le x \le x_n - -where :math:`m_k = (y_{k+1} - y_k)/(x_{k+1} - x_k)` and -:math:`c_k = y_k - m_k\, x_k`. For concave :math:`f` with ``sign="<="``, -the intersection of all chord inequalities equals the hypograph of -:math:`f` on its domain. - -The LP dispatch requires curvature and sign to match: ``sign="<="`` needs -concave (or linear); ``sign=">="`` needs convex (or linear). A mismatch -is *not* just a loose bound — it describes the wrong region (see the -:doc:`piecewise-inequality-bounds-tutorial`). ``method="auto"`` detects -this and falls back; ``method="lp"`` raises. - -.. code-block:: python - - # y <= f(x) on a concave f — auto picks LP - m.add_piecewise_formulation((y, yp, "<="), (x, xp)) - - # Or explicitly: - m.add_piecewise_formulation((y, yp, "<="), (x, xp), method="lp") - -**Not supported with** ``method="lp"``: all-equality, more than 2 tuples, -and ``active``. ``method="auto"`` falls back to SOS2/incremental in all -three cases. - -The underlying chord expressions are also exposed as a standalone helper, -``linopy.tangent_lines(x, x_pts, y_pts)``, which returns the per-segment -chord as a :class:`~linopy.expressions.LinearExpression` with no variables -created. Use it directly if you want to compose the chord bound with other -constraints by hand, without the domain bound that ``method="lp"`` adds -automatically. - Disjunctive (Disaggregated Convex Combination) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -532,45 +538,22 @@ for per-entity breakpoints with ragged lengths: Interior NaN values (gaps in the middle) are not supported and raise an error. -Generated variables and constraints -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Inspecting generated objects +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Given a base name ``N`` (either user-supplied or auto-assigned like ``pwl0``), -each formulation creates a predictable set of names: +The returned :class:`PiecewiseFormulation` exposes ``.variables`` and +``.constraints`` as live views into the model — use them to introspect +exactly what was generated, rather than relying on documented name +conventions: -**SOS2** (``method="sos2"``): - -- ``{N}_lambda`` — variable, interpolation weights -- ``{N}_convex`` — constraint, ``sum(lambda) == 1`` (or ``== active``) -- ``{N}_link`` — constraint, equality link (pinned tuples when one tuple - is bounded; all tuples when all are equality) -- ``{N}_output_link`` — constraint, signed link on the bounded tuple - *(only when one tuple carries* ``"<="`` */* ``">="`` *)* - -**Incremental** (``method="incremental"``): - -- ``{N}_delta`` — variable, fill fractions :math:`\delta_i` -- ``{N}_order_binary`` — variable, per-segment binaries :math:`z_i` -- ``{N}_delta_bound`` — constraint, :math:`\delta_i \le z_i` -- ``{N}_fill_order`` — constraint, :math:`\delta_{i+1} \le \delta_i` -- ``{N}_binary_order`` — constraint, :math:`z_{i+1} \le \delta_i` -- ``{N}_active_bound`` — constraint, :math:`\delta_i \le active` - *(only when* ``active`` *is given)* -- ``{N}_link`` / ``{N}_output_link`` — same split as SOS2 - -**LP** (``method="lp"``): - -- ``{N}_chord`` — constraint, per-segment chord inequality -- ``{N}_domain_lo``, ``{N}_domain_hi`` — constraints, :math:`x_0 \le x \le x_n` -- *no auxiliary variables* +.. code-block:: python -**Disjunctive** (``segments(...)`` input): + f = m.add_piecewise_formulation((y, y_pts, "<="), (x, x_pts)) + print(f) # method, convexity, vars/cons summary -- ``{N}_segment_binary`` — variable, per-segment selectors :math:`z_k` -- ``{N}_select`` — constraint, ``sum(z_k) == 1`` (or ``== active``) -- ``{N}_lambda`` — variable, within-segment weights -- ``{N}_convex`` — constraint, per-segment :math:`\sum_i \lambda_{k,i} = z_k` -- ``{N}_link`` / ``{N}_output_link`` — same split as SOS2 +The comparison table above describes the *kind* of auxiliary objects each +method creates (continuous + SOS2, binary + SOS2, none, …); exact name +suffixes are an implementation detail and may evolve. See Also From 99afee8fa51f5415d0c4d26fd8cce8d4461b42f0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:13:39 +0000 Subject: [PATCH 41/65] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- doc/piecewise-linear-constraints.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 12b19b44..894f0f35 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -549,7 +549,7 @@ conventions: .. code-block:: python f = m.add_piecewise_formulation((y, y_pts, "<="), (x, x_pts)) - print(f) # method, convexity, vars/cons summary + print(f) # method, convexity, vars/cons summary The comparison table above describes the *kind* of auxiliary objects each method creates (continuous + SOS2, binary + SOS2, none, …); exact name From f4161dacb6511aee7716a26e04a336dc46219b68 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:15:59 +0200 Subject: [PATCH 42/65] docs(piecewise): consolidate inequality math in rst, drop notebook duplication - Add a "Formulation" math block to the per-tuple sign section in the rst for the bounded-tuple link split (pinned equality + signed output_link). This was previously only spelled out as a math block in the notebook while the rst described it in prose. - Drop the "Mathematical formulation" cell from the inequality notebook: the all-equality / LP-chord / incremental blocks were verbatim copies of what's already in the rst's method subsections. - Update the notebook's intro to point at the reference page for the math and frame the notebook as geometry / dispatch / feasible-region focused. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/piecewise-linear-constraints.rst | 19 +++++++++ examples/piecewise-inequality-bounds.ipynb | 46 +++++++++++++++------- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 894f0f35..7d518c48 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -123,6 +123,25 @@ Multi-bounded and N≥3-inequality use cases aren't supported yet. If you have a concrete use case, please open an issue at https://github.com/PyPSA/linopy/issues so we can scope it properly. +**Formulation.** For methods that introduce shared interpolation +weights (SOS2 and incremental — see below), only the link constraint +between the weights and the bounded expression changes. Pinned tuples +:math:`j` keep the equality, and the bounded tuple :math:`b` flips to +the requested sign: + +.. math:: + + &e_j = \sum_{i=0}^{n} \lambda_i \, B_{j,i} + \quad \text{(pinned, } j \ne b \text{)} + + &e_b \ \text{sign}\ \sum_{i=0}^{n} \lambda_i \, B_{b,i} + \quad \text{(bounded)} + +Internally this shows up as a stacked ``*_link`` equality covering the +pinned tuples plus a separate signed ``*_output_link`` for the bounded +tuple. The ``method="lp"`` path encodes the same one-sided semantics +without weights — see the LP section below. + **Geometry.** For 2 variables with ``sign="<="`` on a concave curve :math:`f`, the feasible region is the **hypograph** of :math:`f` on its domain: diff --git a/examples/piecewise-inequality-bounds.ipynb b/examples/piecewise-inequality-bounds.ipynb index be5746f0..289876ca 100644 --- a/examples/piecewise-inequality-bounds.ipynb +++ b/examples/piecewise-inequality-bounds.ipynb @@ -3,7 +3,30 @@ { "cell_type": "markdown", "metadata": {}, - "source": "# Piecewise inequalities — per-tuple sign\n\n`add_piecewise_formulation` accepts an optional third tuple element, `\"<=\"` or `\">=\"`, that marks one expression as **bounded** by the piecewise curve instead of pinned to it:\n\n```python\nm.add_piecewise_formulation(\n (fuel, y_pts, \"<=\"), # bounded above by the curve\n (power, x_pts), # pinned to the curve\n)\n```\n\nThis notebook walks through the math, the curvature × sign matching that lets `method=\"auto\"` skip MIP machinery entirely, and the feasible regions produced by each method (LP, SOS2, incremental).\n\n## Key points\n\n| Tuple form | Behaviour |\n|---|---|\n| `(expr, breaks)` | Pinned: `expr` lies exactly on the curve. |\n| `(expr, breaks, \"<=\")` | Bounded above: `expr ≤ f(other tuples)`. |\n| `(expr, breaks, \">=\")` | Bounded below: `expr ≥ f(other tuples)`. |\n\nCurrently at most one tuple may carry a non-equality sign, and 3+ tuples must all be equality. Multi-bounded and N≥3 inequality cases aren't supported yet — if you have a concrete use case, please open an issue at https://github.com/PyPSA/linopy/issues so we can scope it properly." + "source": [ + "# Piecewise inequalities \u2014 per-tuple sign\n", + "\n", + "`add_piecewise_formulation` accepts an optional third tuple element, `\"<=\"` or `\">=\"`, that marks one expression as **bounded** by the piecewise curve instead of pinned to it:\n", + "\n", + "```python\n", + "m.add_piecewise_formulation(\n", + " (fuel, y_pts, \"<=\"), # bounded above by the curve\n", + " (power, x_pts), # pinned to the curve\n", + ")\n", + "```\n", + "\n", + "This notebook walks through the geometry, the curvature \u00d7 sign matching that lets `method=\"auto\"` skip MIP machinery entirely, and the feasible regions produced by each method (LP, SOS2, incremental). For the formulation math see the [reference page](piecewise-linear-constraints).\n", + "\n", + "## Key points\n", + "\n", + "| Tuple form | Behaviour |\n", + "|---|---|\n", + "| `(expr, breaks)` | Pinned: `expr` lies exactly on the curve. |\n", + "| `(expr, breaks, \"<=\")` | Bounded above: `expr \u2264 f(other tuples)`. |\n", + "| `(expr, breaks, \">=\")` | Bounded below: `expr \u2265 f(other tuples)`. |\n", + "\n", + "Currently at most one tuple may carry a non-equality sign, and 3+ tuples must all be equality. Multi-bounded and N\u22653 inequality cases aren't supported yet \u2014 if you have a concrete use case, please open an issue at https://github.com/PyPSA/linopy/issues so we can scope it properly." + ] }, { "cell_type": "code", @@ -25,12 +48,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## Mathematical formulation\n\n### All-equality (every tuple pinned)\n\nFor $N$ expressions $e_1, \\dots, e_N$ with breakpoints $B_{j,0}, \\dots, B_{j,n}$ per expression $j$, the SOS2 formulation introduces interpolation weights $\\lambda_i \\in [0,1]$:\n\n$$\n\\sum_{i=0}^{n} \\lambda_i = 1, \\qquad \\text{SOS2}(\\lambda_0, \\dots, \\lambda_n),\n\\qquad e_j = \\sum_{i=0}^{n} \\lambda_i \\, B_{j,i} \\ \\ \\forall j.\n$$\n\nEvery expression is tied to the same $\\lambda$ — they share a single point on the curve.\n\n### One bounded tuple\n\nWhen tuple $b$ carries a non-equality sign, its link becomes one-sided; the pinned tuples keep the equality:\n\n$$\n\\sum_{i=0}^{n} \\lambda_i = 1, \\qquad \\text{SOS2}(\\lambda),\n\\qquad e_j = \\sum_{i=0}^{n} \\lambda_i \\, B_{j,i}\\ \\ \\forall j \\ne b,\n\\qquad e_b \\ \\text{sign}\\ \\sum_{i=0}^{n} \\lambda_i \\, B_{b,i}.\n$$\n\nThe pinned expressions are tied to a shared $\\lambda$; the bounded one is then bounded (above or below) by the interpolated value. The split is visible in the generated constraints: a single stacked `*_link` for pinned tuples and a separate `*_output_link` carrying the sign.\n\n### LP method (2-variable inequality, convex/concave curve)\n\nFor $y \\le f(x)$ on a concave $f$ (or $y \\ge f(x)$ on convex), we add one tangent (chord) per segment $k$:\n\n$$\ny \\le m_k \\cdot x + c_k \\ \\ \\forall k,\n\\qquad x_0 \\le x \\le x_n,\n$$\n\nwhere $m_k = (y_{k+1}-y_k)/(x_{k+1}-x_k)$ and $c_k = y_k - m_k x_k$. The intersection of all chord inequalities equals the hypograph within the x-domain. No auxiliary variables are created.\n\n### Incremental (delta) formulation\n\nAn MIP alternative to SOS2 for strictly monotonic breakpoints, using fill fractions $\\delta_i \\in [0,1]$ and binaries $z_i$ per segment:\n\n$$\n\\delta_{i+1} \\le \\delta_i, \\quad z_{i+1} \\le \\delta_i, \\quad \\delta_i \\le z_i,\n\\qquad e_j = B_{j,0} + \\sum_i \\delta_i\\,(B_{j,i+1}-B_{j,i}).\n$$\n\nSame split as SOS2: pinned tuples use equality; the bounded one uses the requested sign." - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": "## Setup — a concave curve\n\nWe use a concave, monotonically increasing curve. With a tuple bounded `<=`, the LP method is applicable (concave + `<=` is a tight relaxation)." + "source": "## Setup \u2014 a concave curve\n\nWe use a concave, monotonically increasing curve. With a tuple bounded `<=`, the LP method is applicable (concave + `<=` is a tight relaxation)." }, { "cell_type": "code", @@ -56,7 +74,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## Three methods, identical feasible region\n\nWith one tuple bounded `<=` and our concave curve, the three methods give the **same** feasible region within `[x_0, x_n]`:\n\n- **`method=\"lp\"`** — tangent lines + domain bounds. No auxiliary variables.\n- **`method=\"sos2\"`** — lambdas + SOS2 + split link (pinned equality, bounded signed). Solver picks the segment.\n- **`method=\"incremental\"`** — delta fractions + binaries + split link. Same mathematics, MIP encoding instead of SOS2.\n\n`method=\"auto\"` dispatches to `\"lp\"` whenever applicable — it's always preferable because it's pure LP.\n\nLet's verify they produce the same solution at `power=15`." + "source": "## Three methods, identical feasible region\n\nWith one tuple bounded `<=` and our concave curve, the three methods give the **same** feasible region within `[x_0, x_n]`:\n\n- **`method=\"lp\"`** \u2014 tangent lines + domain bounds. No auxiliary variables.\n- **`method=\"sos2\"`** \u2014 lambdas + SOS2 + split link (pinned equality, bounded signed). Solver picks the segment.\n- **`method=\"incremental\"`** \u2014 delta fractions + binaries + split link. Same mathematics, MIP encoding instead of SOS2.\n\n`method=\"auto\"` dispatches to `\"lp\"` whenever applicable \u2014 it's always preferable because it's pure LP.\n\nLet's verify they produce the same solution at `power=15`." }, { "cell_type": "code", @@ -73,12 +91,12 @@ { "cell_type": "markdown", "metadata": {}, - "source": "All three give `fuel=25` at `power=15` (which is `f(15)` exactly) — the math is equivalent. The LP method is strictly cheaper: no auxiliary variables, just three chord constraints and two domain bounds.\n\nThe SOS2 and incremental methods create lambdas (or deltas + binaries) and split the link into a pinned-equality constraint plus a signed bounded link — but the feasible region is the same." + "source": "All three give `fuel=25` at `power=15` (which is `f(15)` exactly) \u2014 the math is equivalent. The LP method is strictly cheaper: no auxiliary variables, just three chord constraints and two domain bounds.\n\nThe SOS2 and incremental methods create lambdas (or deltas + binaries) and split the link into a pinned-equality constraint plus a signed bounded link \u2014 but the feasible region is the same." }, { "cell_type": "markdown", "metadata": {}, - "source": "## Visualising the feasible region\n\nThe feasible region for `(power, fuel)` with `fuel` bounded `<=` is the **hypograph** of `f` restricted to the curve's x-domain:\n\n$$\\{ (x, y) : x_0 \\le x \\le x_n,\\ y \\le f(x) \\}$$\n\nWe colour green feasible points, red infeasible ones. Three test points:\n\n- `(15, 15)` — inside the curve, `15 ≤ f(15)=25` ✓\n- `(15, 25)` — on the curve ✓\n- `(15, 29)` — above `f(15)`, should be infeasible ✗\n- `(35, 20)` — power beyond domain, infeasible ✗" + "source": "## Visualising the feasible region\n\nThe feasible region for `(power, fuel)` with `fuel` bounded `<=` is the **hypograph** of `f` restricted to the curve's x-domain:\n\n$$\\{ (x, y) : x_0 \\le x \\le x_n,\\ y \\le f(x) \\}$$\n\nWe colour green feasible points, red infeasible ones. Three test points:\n\n- `(15, 15)` \u2014 inside the curve, `15 \u2264 f(15)=25` \u2713\n- `(15, 25)` \u2014 on the curve \u2713\n- `(15, 29)` \u2014 above `f(15)`, should be infeasible \u2717\n- `(35, 20)` \u2014 power beyond domain, infeasible \u2717" }, { "cell_type": "code", @@ -114,7 +132,7 @@ "ax.set(\n", " xlabel=\"power\",\n", " ylabel=\"fuel\",\n", - " title=\"sign='<=' feasible region — hypograph of f(x) on [x_0, x_n]\",\n", + " title=\"sign='<=' feasible region \u2014 hypograph of f(x) on [x_0, x_n]\",\n", ")\n", "ax.grid(alpha=0.3)\n", "ax.legend()\n", @@ -124,7 +142,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## When is LP the right choice?\n\n`tangent_lines` imposes the **intersection** of chord inequalities. Whether that intersection matches the true hypograph/epigraph of `f` depends on the curvature × sign combination:\n\n| curvature | bounded `<=` | bounded `>=` |\n|-----------|--------------|--------------|\n| **concave** | **hypograph (exact ✓)** | **wrong region** — requires `y ≥ max_k chord_k(x) > f(x)` |\n| **convex** | **wrong region** — requires `y ≤ min_k chord_k(x) < f(x)` | **epigraph (exact ✓)** |\n| linear | exact | exact |\n| mixed (non-convex) | convex hull of `f` (wrong for exact hypograph) | concave hull of `f` (wrong for exact epigraph) |\n\nIn the ✗ cases, tangent lines do **not** give a loose relaxation — they give a **strictly wrong feasible region** that rejects points satisfying the true constraint. Example: for a concave `f` with `y ≥ f(x)`, the chord of any segment extrapolated over another segment's x-range lies *above* `f`, so `y ≥ max_k chord_k(x)` forbids `y = f(x)` itself.\n\n`method=\"auto\"` dispatches to LP only in the two **exact** cases (concave + `<=` or convex + `>=`). For the other combinations it falls back to SOS2 or incremental, which encode the hypograph/epigraph exactly via discrete segment selection.\n\n`method=\"lp\"` explicitly forces LP and raises on a mismatched curvature rather than silently producing a wrong feasible region.\n\nFor **non-convex** curves with either sign, the only exact option is a piecewise formulation. That's what the bounded-tuple path does internally: it falls back to SOS2/incremental with the sign on the bounded link. No relaxation, no wrong bounds." + "source": "## When is LP the right choice?\n\n`tangent_lines` imposes the **intersection** of chord inequalities. Whether that intersection matches the true hypograph/epigraph of `f` depends on the curvature \u00d7 sign combination:\n\n| curvature | bounded `<=` | bounded `>=` |\n|-----------|--------------|--------------|\n| **concave** | **hypograph (exact \u2713)** | **wrong region** \u2014 requires `y \u2265 max_k chord_k(x) > f(x)` |\n| **convex** | **wrong region** \u2014 requires `y \u2264 min_k chord_k(x) < f(x)` | **epigraph (exact \u2713)** |\n| linear | exact | exact |\n| mixed (non-convex) | convex hull of `f` (wrong for exact hypograph) | concave hull of `f` (wrong for exact epigraph) |\n\nIn the \u2717 cases, tangent lines do **not** give a loose relaxation \u2014 they give a **strictly wrong feasible region** that rejects points satisfying the true constraint. Example: for a concave `f` with `y \u2265 f(x)`, the chord of any segment extrapolated over another segment's x-range lies *above* `f`, so `y \u2265 max_k chord_k(x)` forbids `y = f(x)` itself.\n\n`method=\"auto\"` dispatches to LP only in the two **exact** cases (concave + `<=` or convex + `>=`). For the other combinations it falls back to SOS2 or incremental, which encode the hypograph/epigraph exactly via discrete segment selection.\n\n`method=\"lp\"` explicitly forces LP and raises on a mismatched curvature rather than silently producing a wrong feasible region.\n\nFor **non-convex** curves with either sign, the only exact option is a piecewise formulation. That's what the bounded-tuple path does internally: it falls back to SOS2/incremental with the sign on the bounded link. No relaxation, no wrong bounds." }, { "cell_type": "code", @@ -136,12 +154,12 @@ } }, "outputs": [], - "source": "# 1. Non-convex curve: auto falls back (LP relaxation would be loose)\nx_nc = [0, 10, 20, 30]\ny_nc = [0, 20, 10, 30] # slopes change sign → mixed convexity\n\nm1 = linopy.Model()\nx1 = m1.add_variables(lower=0, upper=30, name=\"x\")\ny1 = m1.add_variables(lower=0, upper=40, name=\"y\")\nf1 = m1.add_piecewise_formulation((y1, y_nc, \"<=\"), (x1, x_nc))\nprint(f\"non-convex + '<=' → {f1.method}\")\n\n# 2. Concave curve + sign='>=': LP would be loose → auto falls back to MIP\nx_cc = [0, 10, 20, 30]\ny_cc = [0, 20, 30, 35] # concave\n\nm2 = linopy.Model()\nx2 = m2.add_variables(lower=0, upper=30, name=\"x\")\ny2 = m2.add_variables(lower=0, upper=40, name=\"y\")\nf2 = m2.add_piecewise_formulation((y2, y_cc, \">=\"), (x2, x_cc))\nprint(f\"concave + '>=' → {f2.method}\")\n\n# 3. Explicit method=\"lp\" with mismatched curvature raises\ntry:\n m3 = linopy.Model()\n x3 = m3.add_variables(lower=0, upper=30, name=\"x\")\n y3 = m3.add_variables(lower=0, upper=40, name=\"y\")\n m3.add_piecewise_formulation((y3, y_cc, \">=\"), (x3, x_cc), method=\"lp\")\nexcept ValueError as e:\n print(f\"lp(concave, '>=') → raises: {e}\")" + "source": "# 1. Non-convex curve: auto falls back (LP relaxation would be loose)\nx_nc = [0, 10, 20, 30]\ny_nc = [0, 20, 10, 30] # slopes change sign \u2192 mixed convexity\n\nm1 = linopy.Model()\nx1 = m1.add_variables(lower=0, upper=30, name=\"x\")\ny1 = m1.add_variables(lower=0, upper=40, name=\"y\")\nf1 = m1.add_piecewise_formulation((y1, y_nc, \"<=\"), (x1, x_nc))\nprint(f\"non-convex + '<=' \u2192 {f1.method}\")\n\n# 2. Concave curve + sign='>=': LP would be loose \u2192 auto falls back to MIP\nx_cc = [0, 10, 20, 30]\ny_cc = [0, 20, 30, 35] # concave\n\nm2 = linopy.Model()\nx2 = m2.add_variables(lower=0, upper=30, name=\"x\")\ny2 = m2.add_variables(lower=0, upper=40, name=\"y\")\nf2 = m2.add_piecewise_formulation((y2, y_cc, \">=\"), (x2, x_cc))\nprint(f\"concave + '>=' \u2192 {f2.method}\")\n\n# 3. Explicit method=\"lp\" with mismatched curvature raises\ntry:\n m3 = linopy.Model()\n x3 = m3.add_variables(lower=0, upper=30, name=\"x\")\n y3 = m3.add_variables(lower=0, upper=40, name=\"y\")\n m3.add_piecewise_formulation((y3, y_cc, \">=\"), (x3, x_cc), method=\"lp\")\nexcept ValueError as e:\n print(f\"lp(concave, '>=') \u2192 raises: {e}\")" }, { "cell_type": "markdown", "metadata": {}, - "source": "## Summary\n\n- Default is all-equality: every tuple lies on the curve.\n- Append `\"<=\"` or `\">=\"` as a third tuple element to mark one expression as bounded by the curve.\n- `method=\"auto\"` picks the most efficient formulation: LP for matching-curvature 2-variable inequalities, otherwise SOS2 or incremental.\n- At most one tuple may be bounded; with 3+ tuples all must be equality. Multi-bounded and N≥3 inequality use cases — please open an issue at https://github.com/PyPSA/linopy/issues so we can scope them." + "source": "## Summary\n\n- Default is all-equality: every tuple lies on the curve.\n- Append `\"<=\"` or `\">=\"` as a third tuple element to mark one expression as bounded by the curve.\n- `method=\"auto\"` picks the most efficient formulation: LP for matching-curvature 2-variable inequalities, otherwise SOS2 or incremental.\n- At most one tuple may be bounded; with 3+ tuples all must be equality. Multi-bounded and N\u22653 inequality use cases \u2014 please open an issue at https://github.com/PyPSA/linopy/issues so we can scope them." } ], "metadata": { From 2c2b959d26e1cafaec6068d2f4c200b5fe87dd26 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:49:32 +0200 Subject: [PATCH 43/65] =?UTF-8?q?refactor(piecewise):=20rename=20"segment"?= =?UTF-8?q?=20=E2=86=92=20"piece"=20for=20the=20linear-piece=20concept?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both terms are used in the PWL literature — Wikipedia and the Northwestern optimization wiki use them interchangeably; JuMP's PiecewiseLinearOpt.jl prefers "pieces", Pyomo's API leans "segments". So the rename isn't about correctness. The reason is local to this codebase: segments() is a public factory that returns disjoint operating regions for the disjunctive formulation. Using the same word for "linear part of a connected curve" creates avoidable ambiguity — most visibly in the method comparison table, where the row "Segment layout: Connected / Connected / Connected / Disconnected" silently switches meaning between the LP/SOS2/Incremental columns (linear pieces of one curve) and the Disjunctive column (disjoint operating regions). After the rename: - piece — a linear part between adjacent breakpoints on a connected piecewise-linear curve. Used in: LP chord math, SOS2/incremental prose, the tangent_lines dim name (_breakpoint_piece), and LP_PIECE_DIM. - segment — a disjoint operating region in the disjunctive formulation. Used in: the segments() factory, SEGMENT_DIM, and the disjunctive section's prose. segments() keeps its name because it is geometrically accurate (each entry is a segment of the real line, with gaps between them) and renaming the public factory would be churn. The exposed _breakpoint_seg dim was already flagged as evolving via EvolvingAPIWarning, so renaming it now is in scope. Also adds a short Terminology block at the top of the docs so the breakpoint / piece / segment distinction is visible before any prose uses the terms. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/piecewise-linear-constraints.rst | 22 ++++-- examples/piecewise-inequality-bounds.ipynb | 35 ++++++++- linopy/constants.py | 2 +- linopy/piecewise.py | 88 +++++++++++----------- test/test_piecewise_constraints.py | 20 ++--- 5 files changed, 104 insertions(+), 63 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 7d518c48..ff13873c 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -4,9 +4,19 @@ Piecewise Linear Constraints ============================ Piecewise linear (PWL) constraints approximate nonlinear functions as connected -linear segments, allowing you to model cost curves, efficiency curves, or +linear pieces, allowing you to model cost curves, efficiency curves, or production functions within a linear programming framework. +**Terminology used in this page:** + +- **breakpoint** — an :math:`(x, y)` knot where the slope can change. +- **piece** — a linear part between two adjacent breakpoints on a single + connected curve. ``n`` breakpoints define ``n − 1`` pieces. +- **segment** — a *disjoint* operating region in the disjunctive + formulation, built via the :func:`~linopy.segments` factory. Within + one segment the curve is itself piecewise-linear (made of pieces); + between segments there are gaps. + .. contents:: :local: :depth: 2 @@ -333,7 +343,7 @@ At-a-glance comparison: - ``sos2`` - ``incremental`` - Disjunctive - * - Segment layout + * - Curve layout - Connected - Connected - Connected @@ -378,13 +388,13 @@ LP (chord-line) Formulation ~~~~~~~~~~~~~~~~~~~~~~~~~~~ For **2-variable inequality** on a **convex** or **concave** curve. Adds one -chord inequality per segment plus a domain bound — no auxiliary variables and +chord inequality per piece plus a domain bound — no auxiliary variables and no MIP relaxation: .. math:: &y \ \text{sign}\ m_k \cdot x + c_k - \quad \forall\ \text{segments } k + \quad \forall\ \text{pieces } k &x_0 \le x \le x_n @@ -412,7 +422,7 @@ and ``active``. ``method="auto"`` falls back to SOS2/incremental in all three cases. The underlying chord expressions are also exposed as a standalone helper, -``linopy.tangent_lines(x, x_pts, y_pts)``, which returns the per-segment +``linopy.tangent_lines(x, x_pts, y_pts)``, which returns the per-piece chord as a :class:`~linopy.expressions.LinearExpression` with no variables created. Use it directly if you want to compose the chord bound with other constraints by hand, without the domain bound that ``method="lp"`` adds @@ -433,7 +443,7 @@ Works for any breakpoint ordering. Introduces interpolation weights \quad \text{for each expression } j The SOS2 constraint ensures at most two adjacent :math:`\lambda_i` are -non-zero, so every expression is interpolated within the same segment. +non-zero, so every expression is interpolated within the same piece. With a bounded tuple, the pinned tuples still use the equality above; the bounded tuple's link is replaced by a one-sided ``e_b \ \text{sign}\ \sum_i diff --git a/examples/piecewise-inequality-bounds.ipynb b/examples/piecewise-inequality-bounds.ipynb index 289876ca..a79c612d 100644 --- a/examples/piecewise-inequality-bounds.ipynb +++ b/examples/piecewise-inequality-bounds.ipynb @@ -74,7 +74,19 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## Three methods, identical feasible region\n\nWith one tuple bounded `<=` and our concave curve, the three methods give the **same** feasible region within `[x_0, x_n]`:\n\n- **`method=\"lp\"`** \u2014 tangent lines + domain bounds. No auxiliary variables.\n- **`method=\"sos2\"`** \u2014 lambdas + SOS2 + split link (pinned equality, bounded signed). Solver picks the segment.\n- **`method=\"incremental\"`** \u2014 delta fractions + binaries + split link. Same mathematics, MIP encoding instead of SOS2.\n\n`method=\"auto\"` dispatches to `\"lp\"` whenever applicable \u2014 it's always preferable because it's pure LP.\n\nLet's verify they produce the same solution at `power=15`." + "source": [ + "## Three methods, identical feasible region\n", + "\n", + "With one tuple bounded `<=` and our concave curve, the three methods give the **same** feasible region within `[x_0, x_n]`:\n", + "\n", + "- **`method=\"lp\"`** \u2014 tangent lines + domain bounds. No auxiliary variables.\n", + "- **`method=\"sos2\"`** \u2014 lambdas + SOS2 + split link (pinned equality, bounded signed). Solver picks the piece.\n", + "- **`method=\"incremental\"`** \u2014 delta fractions + binaries + split link. Same mathematics, MIP encoding instead of SOS2.\n", + "\n", + "`method=\"auto\"` dispatches to `\"lp\"` whenever applicable \u2014 it's always preferable because it's pure LP.\n", + "\n", + "Let's verify they produce the same solution at `power=15`." + ] }, { "cell_type": "code", @@ -142,7 +154,26 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## When is LP the right choice?\n\n`tangent_lines` imposes the **intersection** of chord inequalities. Whether that intersection matches the true hypograph/epigraph of `f` depends on the curvature \u00d7 sign combination:\n\n| curvature | bounded `<=` | bounded `>=` |\n|-----------|--------------|--------------|\n| **concave** | **hypograph (exact \u2713)** | **wrong region** \u2014 requires `y \u2265 max_k chord_k(x) > f(x)` |\n| **convex** | **wrong region** \u2014 requires `y \u2264 min_k chord_k(x) < f(x)` | **epigraph (exact \u2713)** |\n| linear | exact | exact |\n| mixed (non-convex) | convex hull of `f` (wrong for exact hypograph) | concave hull of `f` (wrong for exact epigraph) |\n\nIn the \u2717 cases, tangent lines do **not** give a loose relaxation \u2014 they give a **strictly wrong feasible region** that rejects points satisfying the true constraint. Example: for a concave `f` with `y \u2265 f(x)`, the chord of any segment extrapolated over another segment's x-range lies *above* `f`, so `y \u2265 max_k chord_k(x)` forbids `y = f(x)` itself.\n\n`method=\"auto\"` dispatches to LP only in the two **exact** cases (concave + `<=` or convex + `>=`). For the other combinations it falls back to SOS2 or incremental, which encode the hypograph/epigraph exactly via discrete segment selection.\n\n`method=\"lp\"` explicitly forces LP and raises on a mismatched curvature rather than silently producing a wrong feasible region.\n\nFor **non-convex** curves with either sign, the only exact option is a piecewise formulation. That's what the bounded-tuple path does internally: it falls back to SOS2/incremental with the sign on the bounded link. No relaxation, no wrong bounds." + "source": [ + "## When is LP the right choice?\n", + "\n", + "`tangent_lines` imposes the **intersection** of chord inequalities. Whether that intersection matches the true hypograph/epigraph of `f` depends on the curvature \u00d7 sign combination:\n", + "\n", + "| curvature | bounded `<=` | bounded `>=` |\n", + "|-----------|--------------|--------------|\n", + "| **concave** | **hypograph (exact \u2713)** | **wrong region** \u2014 requires `y \u2265 max_k chord_k(x) > f(x)` |\n", + "| **convex** | **wrong region** \u2014 requires `y \u2264 min_k chord_k(x) < f(x)` | **epigraph (exact \u2713)** |\n", + "| linear | exact | exact |\n", + "| mixed (non-convex) | convex hull of `f` (wrong for exact hypograph) | concave hull of `f` (wrong for exact epigraph) |\n", + "\n", + "In the \u2717 cases, tangent lines do **not** give a loose relaxation \u2014 they give a **strictly wrong feasible region** that rejects points satisfying the true constraint. Example: for a concave `f` with `y \u2265 f(x)`, the chord of any piece extrapolated over another piece's x-range lies *above* `f`, so `y \u2265 max_k chord_k(x)` forbids `y = f(x)` itself.\n", + "\n", + "`method=\"auto\"` dispatches to LP only in the two **exact** cases (concave + `<=` or convex + `>=`). For the other combinations it falls back to SOS2 or incremental, which encode the hypograph/epigraph exactly via discrete piece selection.\n", + "\n", + "`method=\"lp\"` explicitly forces LP and raises on a mismatched curvature rather than silently producing a wrong feasible region.\n", + "\n", + "For **non-convex** curves with either sign, the only exact option is a piecewise formulation. That's what the bounded-tuple path does internally: it falls back to SOS2/incremental with the sign on the bounded link. No relaxation, no wrong bounds." + ] }, { "cell_type": "code", diff --git a/linopy/constants.py b/linopy/constants.py index 461f7895..ab10fd54 100644 --- a/linopy/constants.py +++ b/linopy/constants.py @@ -58,7 +58,7 @@ PWL_CONVEXITIES: set[str] = {"convex", "concave", "linear", "mixed"} BREAKPOINT_DIM = "_breakpoint" SEGMENT_DIM = "_segment" -LP_SEG_DIM = f"{BREAKPOINT_DIM}_seg" +LP_PIECE_DIM = f"{BREAKPOINT_DIM}_piece" GROUPED_TERM_DIM = "_grouped_term" GROUP_DIM = "_group" FACTOR_DIM = "_factor" diff --git a/linopy/piecewise.py b/linopy/piecewise.py index b29fa007..045447d8 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -25,7 +25,7 @@ GREATER_EQUAL, HELPER_DIMS, LESS_EQUAL, - LP_SEG_DIM, + LP_PIECE_DIM, PWL_ACTIVE_BOUND_SUFFIX, PWL_BINARY_ORDER_SUFFIX, PWL_CHORD_SUFFIX, @@ -171,10 +171,10 @@ def _strip_nan(vals: Sequence[float] | np.ndarray) -> list[float]: 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 +def _rename_to_pieces(da: DataArray, piece_index: np.ndarray) -> DataArray: + """Rename breakpoint dim to piece dim and reassign coordinates.""" + da = da.rename({BREAKPOINT_DIM: LP_PIECE_DIM}) + da[LP_PIECE_DIM] = piece_index return da @@ -332,14 +332,14 @@ def slopes_to_points( x_points: list[float], slopes: list[float], y0: float ) -> list[float]: """ - Convert segment slopes + initial y-value to y-coordinates at each breakpoint. + Convert per-piece slopes + initial y-value to y-coordinates at each breakpoint. Parameters ---------- x_points : list[float] Breakpoint x-coordinates (length n). slopes : list[float] - Slope of each segment (length n-1). + Slope of each piece (length n-1). y0 : float y-value at the first breakpoint. @@ -492,14 +492,14 @@ def _tangent_lines_impl( dx = x_points.diff(BREAKPOINT_DIM) dy = y_points.diff(BREAKPOINT_DIM) - seg_index = np.arange(dx.sizes[BREAKPOINT_DIM]) + piece_index = np.arange(dx.sizes[BREAKPOINT_DIM]) - slopes = _rename_to_segments(dy / dx, seg_index) - x_base = _rename_to_segments( - x_points.isel({BREAKPOINT_DIM: slice(None, -1)}), seg_index + slopes = _rename_to_pieces(dy / dx, piece_index) + x_base = _rename_to_pieces( + x_points.isel({BREAKPOINT_DIM: slice(None, -1)}), piece_index ) - y_base = _rename_to_segments( - y_points.isel({BREAKPOINT_DIM: slice(None, -1)}), seg_index + y_base = _rename_to_pieces( + y_points.isel({BREAKPOINT_DIM: slice(None, -1)}), piece_index ) intercepts = y_base - slopes * x_base @@ -519,8 +519,8 @@ def tangent_lines( Compute tangent-line (chord) expressions for a piecewise linear function. Low-level helper returning a :class:`~linopy.expressions.LinearExpression` - with an extra segment dimension. Each element along the segment dimension - is the chord of one segment: :math:`m_k \cdot x + c_k`. No auxiliary + with an extra piece dimension. Each element along the piece dimension + is the chord of one piece: :math:`m_k \cdot x + c_k`. No auxiliary variables are created. For most users: prefer :func:`add_piecewise_formulation` with a @@ -550,20 +550,20 @@ def tangent_lines( Returns ------- LinearExpression - Expression with an additional ``_breakpoint_seg`` dimension - (one entry per segment). + Expression with an additional ``_breakpoint_piece`` dimension + (one entry per piece). Warns ----- EvolvingAPIWarning ``tangent_lines`` is part of the newly-added piecewise API; the - returned expression shape and segment-dim name may be refined. + returned expression shape and piece-dim name may be refined. Silence with ``warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)``. """ warnings.warn( "piecewise: tangent_lines is a new API; the returned expression " - "shape and the segment-dim name may be refined in minor releases. " + "shape and the piece-dim name may be refined in minor releases. " "Please share your use cases or concerns at " "https://github.com/PyPSA/linopy/issues — your feedback shapes " "what stabilises. Silence with " @@ -752,7 +752,7 @@ def add_piecewise_formulation( ``(expression, breakpoints, sign)`` to mark that expression as bounded by the piecewise curve rather than pinned to it. All expressions are linked through shared interpolation weights so that every operating - point lies on the same segment of the piecewise curve. + point lies on the same piece of the piecewise curve. Example — 2 variables (joint equality, the default):: @@ -1386,16 +1386,16 @@ def _add_incremental( stacked_bp = links.stacked_bp extra = _var_coords_from(stacked_bp, exclude={dim, links.link_dim}) - 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] + n_pieces = stacked_bp.sizes[dim] - 1 + piece_dim = f"{dim}_piece" + piece_index = pd.Index(range(n_pieces), name=piece_dim) + delta_coords = extra + [piece_index] if links.bp_mask is not None: - mask_lo = links.bp_mask.isel({dim: slice(None, -1)}).rename({dim: seg_dim}) - mask_hi = links.bp_mask.isel({dim: slice(1, None)}).rename({dim: seg_dim}) - mask_lo[seg_dim] = seg_index - mask_hi[seg_dim] = seg_index + mask_lo = links.bp_mask.isel({dim: slice(None, -1)}).rename({dim: piece_dim}) + mask_hi = links.bp_mask.isel({dim: slice(1, None)}).rename({dim: piece_dim}) + mask_lo[piece_dim] = piece_index + mask_hi[piece_dim] = piece_index delta_mask: DataArray | None = mask_lo & mask_hi else: delta_mask = None @@ -1423,25 +1423,25 @@ def _add_incremental( delta_var <= binary_var, name=f"{name}{PWL_DELTA_BOUND_SUFFIX}" ) - 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) + if n_pieces >= 2: + delta_lo = delta_var.isel({piece_dim: slice(None, -1)}, drop=True) + delta_hi = delta_var.isel({piece_dim: slice(1, None)}, drop=True) model.add_constraints( delta_hi <= delta_lo, name=f"{name}{PWL_FILL_ORDER_SUFFIX}" ) - binary_hi = binary_var.isel({seg_dim: slice(1, None)}, drop=True) + binary_hi = binary_var.isel({piece_dim: slice(1, None)}, drop=True) model.add_constraints( binary_hi <= delta_lo, name=f"{name}{PWL_BINARY_ORDER_SUFFIX}" ) def _incremental_weighted(bp: DataArray) -> LinearExpression: - steps = bp.diff(dim).rename({dim: seg_dim}) - steps[seg_dim] = seg_index + steps = bp.diff(dim).rename({dim: piece_dim}) + steps[piece_dim] = piece_index bp0 = bp.isel({dim: 0}) bp0_term: DataArray | LinearExpression = bp0 if active is not None: bp0_term = bp0 * active - return (delta_var * steps).sum(dim=seg_dim) + bp0_term + return (delta_var * steps).sum(dim=piece_dim) + bp0_term if links.eq_expr is not None and links.eq_bp is not None: model.add_constraints( @@ -1560,20 +1560,20 @@ def _add_lp( """ LP tangent-line formulation (no auxiliary variables). - Adds one chord constraint per segment plus domain bounds on x. - Trailing-NaN segments (per-entity short curves) are masked out so + Adds one chord constraint per piece plus domain bounds on x. + Trailing-NaN pieces (per-entity short curves) are masked out so they do not contribute spurious ``y ≤ 0`` constraints. """ - # Per-segment validity: both endpoints must be non-NaN. + # Per-piece validity: both endpoints must be non-NaN. bp_valid = ~(x_points.isnull() | y_points.isnull()) - seg_count = x_points.sizes[BREAKPOINT_DIM] - 1 - seg_index = np.arange(seg_count) - full_mask = _rename_to_segments( + piece_count = x_points.sizes[BREAKPOINT_DIM] - 1 + piece_index = np.arange(piece_count) + full_mask = _rename_to_pieces( bp_valid.isel({BREAKPOINT_DIM: slice(None, -1)}) & bp_valid.isel({BREAKPOINT_DIM: slice(1, None)}).values, - seg_index, + piece_index, ) - seg_mask: DataArray | None = None if bool(full_mask.all()) else full_mask + piece_mask: DataArray | None = None if bool(full_mask.all()) else full_mask # Use the internal impl so we don't fire a second EvolvingAPIWarning — # ``add_piecewise_formulation`` already warned on entry. @@ -1584,7 +1584,7 @@ def _add_lp( tangents, sign, f"{name}{PWL_CHORD_SUFFIX}", - mask=seg_mask, + mask=piece_mask, ) # Domain bounds: x ∈ [x_min, x_max] (skipna by default). diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 8031df63..fc7ace5f 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -21,7 +21,7 @@ ) from linopy.constants import ( BREAKPOINT_DIM, - LP_SEG_DIM, + LP_PIECE_DIM, PWL_ACTIVE_BOUND_SUFFIX, PWL_BINARY_ORDER_SUFFIX, PWL_CHORD_SUFFIX, @@ -373,21 +373,21 @@ def test_basic_variable(self) -> None: m = Model() x = m.add_variables(name="x", lower=0, upper=100) env = tangent_lines(x, [0, 50, 100], [0, 40, 60]) - assert LP_SEG_DIM in env.dims + assert LP_PIECE_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 = tangent_lines(1 * x, [0, 50, 100], [0, 40, 60]) - assert LP_SEG_DIM in env.dims + assert LP_PIECE_DIM in env.dims - def test_segment_count(self) -> None: - """Number of segments = number of breakpoints - 1.""" + def test_piece_count(self) -> None: + """Number of pieces = number of breakpoints - 1.""" m = Model() x = m.add_variables(name="x") env = tangent_lines(x, [0, 50, 100], [0, 40, 60]) - assert env.sizes[LP_SEG_DIM] == 2 + assert env.sizes[LP_PIECE_DIM] == 2 def test_invalid_x_type_raises(self) -> None: with pytest.raises(TypeError, match="must be a Variable or LinearExpression"): @@ -418,7 +418,7 @@ def test_dataarray_breakpoints(self) -> None: x_pts = xr.DataArray([0, 50, 100], dims=[BREAKPOINT_DIM]) y_pts = xr.DataArray([0, 40, 60], dims=[BREAKPOINT_DIM]) env = tangent_lines(x, x_pts, y_pts) - assert LP_SEG_DIM in env.dims + assert LP_PIECE_DIM in env.dims # =========================================================================== @@ -438,7 +438,7 @@ def test_creates_delta_vars(self) -> None: ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"] - assert delta.labels.sizes[LP_SEG_DIM] == 3 + assert delta.labels.sizes[LP_PIECE_DIM] == 3 assert f"pwl0{PWL_FILL_ORDER_SUFFIX}" in m.constraints assert f"pwl0{PWL_LAMBDA_SUFFIX}" not in m.variables @@ -475,7 +475,7 @@ def test_two_breakpoints_no_fill(self) -> None: method="incremental", ) delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"] - assert delta.labels.sizes[LP_SEG_DIM] == 1 + assert delta.labels.sizes[LP_PIECE_DIM] == 1 assert f"pwl0{PWL_LINK_SUFFIX}" in m.constraints # N-var path uses a single stacked link constraint (no separate y_link) @@ -490,7 +490,7 @@ def test_creates_binary_indicator_vars(self) -> None: ) assert f"pwl0{PWL_ORDER_BINARY_SUFFIX}" in m.variables binary = m.variables[f"pwl0{PWL_ORDER_BINARY_SUFFIX}"] - assert binary.labels.sizes[LP_SEG_DIM] == 3 + assert binary.labels.sizes[LP_PIECE_DIM] == 3 assert f"pwl0{PWL_DELTA_BOUND_SUFFIX}" in m.constraints def test_creates_order_constraints(self) -> None: From f08b41ba13d8e55a8d49daf3c05098c029923cd7 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:53:30 +0200 Subject: [PATCH 44/65] docs(piecewise): reorder methods to match auto-dispatch (Incremental before SOS2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-dispatch (piecewise.py:1285) picks Incremental over SOS2 whenever breakpoints are strictly monotonic — Incremental is the default for connected curves, with SOS2 reserved as the fallback for non-monotonic layouts. The doc had the opposite ordering (LP → SOS2 → Incremental), which made SOS2 look like the canonical MIP encoding. Reorder the comparison table columns and method subsections to: LP → Incremental → SOS2 → Disjunctive, matching dispatch preference. Also link the SOS2 section to :ref:`sos-reformulation` so users can see the actual Big-M MIP form their solver receives when reformulate_sos applies — that's the math most users effectively get, not the abstract SOS2 adjacency constraint. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/piecewise-linear-constraints.rst | 79 ++++++++++++++++------------ 1 file changed, 44 insertions(+), 35 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index ff13873c..8e4174df 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -340,8 +340,8 @@ At-a-glance comparison: * - Property - ``lp`` - - ``sos2`` - ``incremental`` + - ``sos2`` - Disjunctive * - Curve layout - Connected @@ -360,8 +360,8 @@ At-a-glance comparison: - ≥ 2 (3+ requires all ``==``) * - Breakpoint order - Strictly monotonic - - Any - Strictly monotonic + - Any - Any (per segment) * - Curvature requirement - Concave (``<=``) or convex (``>=``) @@ -370,8 +370,8 @@ At-a-glance comparison: - None * - Auxiliary variables - **None** - - Continuous + SOS2 - Continuous + binary + - Continuous + SOS2 - Binary + SOS2 * - ``active=`` supported - No @@ -380,8 +380,8 @@ At-a-glance comparison: - Yes * - Solver requirement - **Any LP solver** - - SOS2-capable - MIP-capable + - SOS2-capable - SOS2 + MIP LP (chord-line) Formulation @@ -428,58 +428,67 @@ created. Use it directly if you want to compose the chord bound with other constraints by hand, without the domain bound that ``method="lp"`` adds automatically. -SOS2 (Convex Combination) -~~~~~~~~~~~~~~~~~~~~~~~~~~ +Incremental (Delta) Formulation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Works for any breakpoint ordering. Introduces interpolation weights -:math:`\lambda_i` with an SOS2 adjacency constraint: +The default MIP encoding when ``method="auto"`` is in play and breakpoints +are **strictly monotonic** — produces a tight MIP directly, without going +through an SOS2 layer. Uses fill-fraction variables :math:`\delta_i` with +binary indicators :math:`z_i`: .. math:: - &\sum_{i=0}^{n} \lambda_i = 1, \qquad - \text{SOS2}(\lambda_0, \ldots, \lambda_n) - - &e_j = \sum_{i=0}^{n} \lambda_i \, B_{j,i} - \quad \text{for each expression } j - -The SOS2 constraint ensures at most two adjacent :math:`\lambda_i` are -non-zero, so every expression is interpolated within the same piece. + &\delta_i \in [0, 1], \quad z_i \in \{0, 1\} -With a bounded tuple, the pinned tuples still use the equality above; the -bounded tuple's link is replaced by a one-sided ``e_b \ \text{sign}\ \sum_i -\lambda_i B_{b,i}`` constraint. + &\delta_{i+1} \le \delta_i, \quad z_{i+1} \le \delta_i, \quad \delta_i \le z_i -.. note:: + &e_j = B_{j,0} + \sum_{i=1}^{n} \delta_i \, (B_{j,i} - B_{j,i-1}) - SOS2 is handled via branch-and-bound, similar to integer variables. - Prefer ``method="incremental"`` when breakpoints are monotonic. +With a bounded tuple, the link to that tuple's expression flips to the +requested sign while the pinned tuples keep the equality above (see +the *Per-tuple sign* section's *Formulation* block). .. code-block:: python - m.add_piecewise_formulation((power, xp), (fuel, yp), method="sos2") + m.add_piecewise_formulation((power, xp), (fuel, yp), method="incremental") -Incremental (Delta) Formulation -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +**Limitation:** breakpoint sequences must be strictly monotonic. + +SOS2 (Convex Combination) +~~~~~~~~~~~~~~~~~~~~~~~~~~ -For **strictly monotonic** breakpoints. Uses fill-fraction variables -:math:`\delta_i` with binary indicators :math:`z_i`: +Fallback when breakpoints aren't strictly monotonic (the only case +``method="auto"`` does not pick incremental for a connected curve). +Introduces interpolation weights :math:`\lambda_i` with an SOS2 +adjacency constraint: .. math:: - &\delta_i \in [0, 1], \quad z_i \in \{0, 1\} + &\sum_{i=0}^{n} \lambda_i = 1, \qquad + \text{SOS2}(\lambda_0, \ldots, \lambda_n) - &\delta_{i+1} \le \delta_i, \quad z_{i+1} \le \delta_i, \quad \delta_i \le z_i + &e_j = \sum_{i=0}^{n} \lambda_i \, B_{j,i} + \quad \text{for each expression } j - &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 piece. +With a bounded tuple, the bounded link flips to the requested sign as +above. -With a bounded tuple the same split as SOS2 applies: pinned tuples use the -equality above; the bounded tuple's link uses the requested sign. +.. note:: -.. code-block:: python + Solvers with native SOS2 support handle the adjacency constraint via + branch-and-bound. Solvers without it see the Big-M reformulation + linopy applies (controlled by ``reformulate_sos=`` on ``solve``) — + see :ref:`sos-reformulation` for the reformulated MIP form, which is + the model those solvers actually receive. When breakpoints are + monotonic, prefer ``method="incremental"`` (or just ``"auto"``): it + builds a similar MIP encoding directly and does not depend on + solver SOS2 support or the reformulation step. - m.add_piecewise_formulation((power, xp), (fuel, yp), method="incremental") +.. code-block:: python -**Limitation:** breakpoint sequences must be strictly monotonic. + m.add_piecewise_formulation((power, xp), (fuel, yp), method="sos2") Disjunctive (Disaggregated Convex Combination) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 33a5d453c50a15261c8305dce67843116fce9342 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:57:19 +0200 Subject: [PATCH 45/65] docs(piecewise): nest tutorial notebooks under reference page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group the two piecewise tutorial notebooks under the reference page via a sub-toctree, instead of listing them as flat sibling entries in the top-level User Guide toctree. The reference page becomes the natural landing for piecewise content: sidebar shows reference → [equality tutorial, inequality tutorial], and the User Guide toctree is freed up to scale when triangulation / 2-D piecewise lands. No file moves and existing :doc: cross-references keep resolving — the notebook document names are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/index.rst | 2 -- doc/piecewise-linear-constraints.rst | 13 +++++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/doc/index.rst b/doc/index.rst index ed85bf6e..a4d34ce7 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -114,8 +114,6 @@ This package is published under MIT license. coordinate-alignment sos-constraints piecewise-linear-constraints - piecewise-linear-constraints-tutorial - piecewise-inequality-bounds-tutorial manipulating-models testing-framework transport-tutorial diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 8e4174df..78f4ecd7 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -594,11 +594,16 @@ method creates (continuous + SOS2, binary + SOS2, none, …); exact name suffixes are an implementation detail and may evolve. +Tutorials +--------- + +.. toctree:: + :maxdepth: 1 + + piecewise-linear-constraints-tutorial + piecewise-inequality-bounds-tutorial + See Also -------- -- :doc:`piecewise-linear-constraints-tutorial` — worked examples of the - equality API (notebook) -- :doc:`piecewise-inequality-bounds-tutorial` — per-tuple sign and the LP - formulation (notebook) - :doc:`sos-constraints` — low-level SOS1/SOS2 constraint API From b4273d31eb5fdb69b80f9990f1cc4e07e086271c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:01:02 +0200 Subject: [PATCH 46/65] docs(release-notes): align with piece/segment terminology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two release-note entries used "segment" for the meaning that the rest of the codebase now calls "piece" (linear part between adjacent breakpoints): - tangent_lines: "per-segment chord" → "per-piece chord" - slopes_to_points: "segment slopes" → "per-piece slopes" linopy.segments() is unchanged — it remains the public factory for disjoint operating regions in the disjunctive formulation. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/release_notes.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 34292d36..69c6895e 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -15,10 +15,10 @@ Upcoming Version * Add one-sided piecewise bounds via a per-tuple sign on ``add_piecewise_formulation``: append ``"<="`` or ``">="`` as a third tuple element — e.g. ``(fuel, y_pts, "<=")`` — to mark that expression as bounded by the curve while the others remain pinned. At most one tuple may carry a non-equality sign; with 3 or more tuples all signs must be ``"=="``. On convex/concave curves with a matching sign, ``method="auto"`` dispatches to a pure-LP chord formulation (``method="lp"``) with no auxiliary variables and automatic domain bounds on the input. Mismatched curvature+sign is detected and falls back to SOS2/incremental with an explanatory info log. * Add unit-commitment gating via the ``active`` parameter on ``add_piecewise_formulation``: a binary variable that, when zero, forces all auxiliary variables (and thus the linked expressions) to zero. Works with the SOS2, incremental, and disjunctive methods. * Surface formulation metadata on the returned ``PiecewiseFormulation``: ``.method`` (resolved method name) and ``.convexity`` (``"convex"`` / ``"concave"`` / ``"linear"`` / ``"mixed"`` when well-defined). Both persist across netCDF round-trip. -* Add ``tangent_lines()`` as a low-level helper that returns per-segment chord expressions as a ``LinearExpression`` — no variables created. Most users should prefer ``add_piecewise_formulation`` with a bounded tuple ``(y, y_pts, "<=")``, which builds on this helper and adds domain bounds and curvature validation. +* Add ``tangent_lines()`` as a low-level helper that returns per-piece chord expressions as a ``LinearExpression`` — no variables created. Most users should prefer ``add_piecewise_formulation`` with a bounded tuple ``(y, y_pts, "<=")``, which builds on this helper and adds domain bounds and curvature validation. * 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 ``slopes_to_points()`` utility for converting segment slopes to breakpoint y-coordinates. +* Add ``slopes_to_points()`` utility for converting per-piece 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 2d2e851dd2f444ac4b0c9b82ed09f5863856195d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:48:00 +0200 Subject: [PATCH 47/65] docs(release-notes): tighten piecewise entries, drop refactor-flavored leftovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The headline ``add_piecewise_formulation`` entry named "the per-tuple sign convention" as something that "may be refined" — but the per-tuple sign IS the shipped convention; what may shift are the restrictions within it (at most one bounded tuple, N≥3 all equality). Reword to name those concrete restrictions as the change candidates. - Drop the "active + non-equality sign semantics" mention — that was a corner case resolved during the refactor, not something users need to see in the headline note. - Fold the three breakpoint-construction helpers (breakpoints, segments, slopes_to_points) into a single line — eight piecewise bullets was more granular than the feature warrants. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/release_notes.rst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 69c6895e..52d7526a 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -11,14 +11,12 @@ 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_formulation()`` for piecewise linear constraints with SOS2, incremental, and disjunctive formulations: ``m.add_piecewise_formulation((power, x_pts), (fuel, y_pts))``. Supports N-variable linking (e.g. CHP with fuel/power/heat) and per-entity breakpoints. ``method="auto"`` picks the cheapest correct formulation automatically. The API is newly added and emits an :class:`linopy.EvolvingAPIWarning` to signal that details (e.g. the per-tuple sign convention, ``active`` + non-equality sign semantics) may be refined in minor releases — feedback and use cases at https://github.com/PyPSA/linopy/issues shape what stabilises. Silence with ``warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)``. +* Add ``add_piecewise_formulation()`` for piecewise linear constraints with SOS2, incremental, and disjunctive formulations: ``m.add_piecewise_formulation((power, x_pts), (fuel, y_pts))``. Supports N-variable linking (e.g. CHP with fuel/power/heat) and per-entity breakpoints. ``method="auto"`` picks the cheapest correct formulation automatically. The API is newly added and emits an :class:`linopy.EvolvingAPIWarning` to signal that details may be refined in minor releases — the current restrictions on per-tuple sign (at most one bounded tuple, N≥3 must be all equality) are the most likely candidates to relax as use cases come in. Feedback and use cases at https://github.com/PyPSA/linopy/issues shape what stabilises. Silence with ``warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)``. * Add one-sided piecewise bounds via a per-tuple sign on ``add_piecewise_formulation``: append ``"<="`` or ``">="`` as a third tuple element — e.g. ``(fuel, y_pts, "<=")`` — to mark that expression as bounded by the curve while the others remain pinned. At most one tuple may carry a non-equality sign; with 3 or more tuples all signs must be ``"=="``. On convex/concave curves with a matching sign, ``method="auto"`` dispatches to a pure-LP chord formulation (``method="lp"``) with no auxiliary variables and automatic domain bounds on the input. Mismatched curvature+sign is detected and falls back to SOS2/incremental with an explanatory info log. * Add unit-commitment gating via the ``active`` parameter on ``add_piecewise_formulation``: a binary variable that, when zero, forces all auxiliary variables (and thus the linked expressions) to zero. Works with the SOS2, incremental, and disjunctive methods. * Surface formulation metadata on the returned ``PiecewiseFormulation``: ``.method`` (resolved method name) and ``.convexity`` (``"convex"`` / ``"concave"`` / ``"linear"`` / ``"mixed"`` when well-defined). Both persist across netCDF round-trip. * Add ``tangent_lines()`` as a low-level helper that returns per-piece chord expressions as a ``LinearExpression`` — no variables created. Most users should prefer ``add_piecewise_formulation`` with a bounded tuple ``(y, y_pts, "<=")``, which builds on this helper and adds domain bounds and curvature validation. -* 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 ``slopes_to_points()`` utility for converting per-piece slopes to breakpoint y-coordinates. +* Add ``linopy.breakpoints()`` (lists/Series/DataFrame/DataArray/dict, plus a slopes-mode constructor), ``linopy.segments()`` (disjunctive operating regions), and ``slopes_to_points()`` (per-piece slopes → breakpoint y-coordinates) as breakpoint-construction helpers. * 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 f2a8647b0ad2ed9de6d2afce3ccc2de7a3a59aed Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 4 May 2026 17:15:38 +0200 Subject: [PATCH 48/65] refactor(expressions): narrow __eq__ type-ignore to [override] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The descriptor pattern is gone, so the bare ``# type: ignore`` over-suppressed mypy. ``__eq__`` still needs to declare ``Constraint`` instead of ``bool`` to preserve linopy's expression-equals-builds-constraint semantics, so the override-mismatch is intrinsic — narrow the directive to ``[override]``. Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/expressions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linopy/expressions.py b/linopy/expressions.py index 49ab93bb..4d7b0673 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -653,7 +653,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: # type: ignore + def __eq__(self, rhs: SideLike) -> Constraint: # type: ignore[override] return self.to_constraint(EQUAL, rhs) def __gt__(self, other: Any) -> NotImplementedType: From 09d096bcdfeee299352a2c0bc74ff1f116cb8afb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 4 May 2026 17:15:49 +0200 Subject: [PATCH 49/65] refactor(io): rename piecewise netcdf attrs to variable_names/constraint_names Use the same key names that ``PiecewiseFormulation`` uses internally (``variable_names``/``constraint_names``) so the netcdf attribute layout matches the dataclass field names. Read path updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/io.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/linopy/io.py b/linopy/io.py index ed32cf1d..8587dfa2 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -1152,8 +1152,8 @@ def with_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: { name: { "method": pwl.method, - "variables": pwl.variable_names, - "constraints": pwl.constraint_names, + "variable_names": pwl.variable_names, + "constraint_names": pwl.constraint_names, "convexity": pwl.convexity, } for name, pwl in m._piecewise_formulations.items() @@ -1263,8 +1263,8 @@ def get_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: m._piecewise_formulations[name] = PiecewiseFormulation( name=name, method=d["method"], - variable_names=d["variables"], - constraint_names=d["constraints"], + variable_names=d["variable_names"], + constraint_names=d["constraint_names"], model=m, convexity=d.get("convexity"), ) From 0b5f338f12fc69e894b390589959d875fe75055a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 4 May 2026 17:25:30 +0200 Subject: [PATCH 50/65] refactor(constants): expose PWL_METHOD/PWL_CONVEXITY Literals Promote the method and convexity string sets to Literal type aliases and derive the runtime sets from ``get_args``. ``piecewise.py`` now uses the aliases for type annotations on ``add_piecewise_formulation``, ``_detect_convexity``, and ``PiecewiseFormulation.convexity`` instead of inlining the string list at every site. Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/constants.py | 8 +++++--- linopy/piecewise.py | 12 ++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/linopy/constants.py b/linopy/constants.py index ab10fd54..94bdb1a0 100644 --- a/linopy/constants.py +++ b/linopy/constants.py @@ -6,7 +6,7 @@ import logging from dataclasses import dataclass, field from enum import Enum -from typing import Any, Union +from typing import Any, Literal, TypeAlias, Union, get_args import numpy as np import pandas as pd @@ -54,8 +54,10 @@ PWL_DOMAIN_LO_SUFFIX = "_domain_lo" PWL_DOMAIN_HI_SUFFIX = "_domain_hi" -PWL_METHODS: set[str] = {"sos2", "lp", "incremental", "auto"} -PWL_CONVEXITIES: set[str] = {"convex", "concave", "linear", "mixed"} +PWL_METHOD: TypeAlias = Literal["sos2", "lp", "incremental", "auto"] +PWL_METHODS: set[str] = set(get_args(PWL_METHOD)) +PWL_CONVEXITY: TypeAlias = Literal["convex", "concave", "linear", "mixed"] +PWL_CONVEXITIES: set[str] = set(get_args(PWL_CONVEXITY)) BREAKPOINT_DIM = "_breakpoint" SEGMENT_DIM = "_segment" LP_PIECE_DIM = f"{BREAKPOINT_DIM}_piece" diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 045447d8..e93c9182 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -30,6 +30,7 @@ PWL_BINARY_ORDER_SUFFIX, PWL_CHORD_SUFFIX, PWL_CONVEX_SUFFIX, + PWL_CONVEXITY, PWL_DELTA_BOUND_SUFFIX, PWL_DELTA_SUFFIX, PWL_DOMAIN_HI_SUFFIX, @@ -37,6 +38,7 @@ PWL_FILL_ORDER_SUFFIX, PWL_LAMBDA_SUFFIX, PWL_LINK_SUFFIX, + PWL_METHOD, PWL_METHODS, PWL_ORDER_BINARY_SUFFIX, PWL_OUTPUT_LINK_SUFFIX, @@ -115,7 +117,7 @@ def __init__( variable_names: list[str], constraint_names: list[str], model: Model, - convexity: Literal["convex", "concave", "linear", "mixed"] | None = None, + convexity: PWL_CONVEXITY | None = None, ) -> None: self.name = name self.method = method @@ -638,9 +640,7 @@ def _check_strict_monotonicity(bp: DataArray) -> bool: return bool(monotonic.all()) -def _detect_convexity( - x_points: DataArray, y_points: DataArray -) -> Literal["convex", "concave", "linear", "mixed"]: +def _detect_convexity(x_points: DataArray, y_points: DataArray) -> PWL_CONVEXITY: """ Classify the shape of a single piecewise curve ``y = f(x)``. @@ -740,7 +740,7 @@ def add_piecewise_formulation( model: Model, *pairs: tuple[LinExprLike, BreaksLike] | tuple[LinExprLike, BreaksLike, Literal["==", "<=", ">="]], - method: Literal["sos2", "incremental", "lp", "auto"] = "auto", + method: PWL_METHOD = "auto", active: LinExprLike | None = None, name: str | None = None, **kwargs: object, @@ -1031,7 +1031,7 @@ def add_piecewise_formulation( "" if inputs.n_tuples == 1 else "s", ) - convexity: Literal["convex", "concave", "linear", "mixed"] | None = None + convexity: PWL_CONVEXITY | None = None if inputs.n_tuples == 2 and not disjunctive: if inputs.is_equality: x_pts = inputs.pinned_bps[1] From 851aa19a0df800ae99fce7a403a6b49e6f83ab53 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 4 May 2026 17:27:32 +0200 Subject: [PATCH 51/65] refactor(repr): centralise piecewise repr in piecewise.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the piecewise summary block out of ``Model.__repr__`` into ``piecewise._repr_summary``; the model now adds the section with a single call. Also drops the ``_repr_filtered`` wrappers on ``Constraints``/``Variables`` — the model calls ``_format_items(exclude=...)`` directly, since that's all the wrappers were doing. The variable and constraint name sets are returned separately from ``_grouped_names`` (variables and constraints live in independent namespaces in the model, so each filter applies to its own collection). Restores the missing docstrings on ``Constraints.__repr__`` and ``Variables.__repr__`` and corrects the wording — they describe the respective container, not the model. Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/constraints.py | 7 +++---- linopy/model.py | 40 ++++++++-------------------------------- linopy/piecewise.py | 43 +++++++++++++++++++++++++++++++++++++++++++ linopy/variables.py | 7 +++---- 4 files changed, 57 insertions(+), 40 deletions(-) diff --git a/linopy/constraints.py b/linopy/constraints.py index a846b681..631f7281 100644 --- a/linopy/constraints.py +++ b/linopy/constraints.py @@ -752,16 +752,15 @@ def _format_items(self, exclude: set[str] | None = None) -> str: return r def __repr__(self) -> str: + """ + Return a string representation of the constraints container. + """ r = "linopy.model.Constraints" line = "-" * len(r) r += f"\n{line}\n" r += self._format_items() return r - def _repr_filtered(self, exclude: set[str]) -> str: - """Format items excluding grouped names (used by Model.__repr__).""" - return self._format_items(exclude) - @overload def __getitem__(self, names: str) -> Constraint: ... diff --git a/linopy/model.py b/linopy/model.py index 9981ce57..c70da9a9 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -503,45 +503,21 @@ def __repr__(self) -> str: """ Return a string representation of the linopy model. """ - grouped_names = self._piecewise_names() - var_string = self.variables._repr_filtered(grouped_names) - con_string = self.constraints._repr_filtered(grouped_names) + from linopy.piecewise import _grouped_names, _repr_summary + + var_names, con_names = _grouped_names(self) + var_string = self.variables._format_items(exclude=var_names) + con_string = self.constraints._format_items(exclude=con_names) model_string = f"Linopy {self.type} model" - result = ( + return ( f"{model_string}\n{'=' * len(model_string)}\n\n" f"Variables:\n----------\n{var_string}\n" f"Constraints:\n------------\n{con_string}" + f"{_repr_summary(self)}" + f"\nStatus:\n-------\n{self.status}" ) - if self._piecewise_formulations: - result += "\nPiecewise Formulations:\n----------------------\n" - for pwl in self._piecewise_formulations.values(): - n_vars = len(pwl.variables) - n_cons = len(pwl.constraints) - # Collect user-facing dims (skip internal _ prefixed dims) - user_dims: list[str] = [] - for var in pwl.variables.data.values(): - for d in var.coords: - if not str(d).startswith("_") and str(d) not in user_dims: - user_dims.append(str(d)) - dims_str = f" ({', '.join(user_dims)})" if user_dims else "" - result += ( - f" * {pwl.name}{dims_str}" - f" — {pwl.method}, {n_vars} vars, {n_cons} cons\n" - ) - - result += f"\nStatus:\n-------\n{self.status}" - return result - - def _piecewise_names(self) -> set[str]: - """Return all variable/constraint names belonging to piecewise formulations.""" - names: set[str] = set() - for pwl in self._piecewise_formulations.values(): - names.update(pwl.variable_names) - names.update(pwl.constraint_names) - return names - def __getitem__(self, key: str) -> Variable: """ Get a model variable by the name. diff --git a/linopy/piecewise.py b/linopy/piecewise.py index e93c9182..3a3e6019 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -136,6 +136,16 @@ def constraints(self) -> Constraints: """View of the auxiliary constraints in this formulation.""" return self._model.constraints[self.constraint_names] + def _user_dims(self) -> list[str]: + """User-facing dim names across this formulation's auxiliary variables.""" + dims: list[str] = [] + for var in self.variables.data.values(): + for d in var.coords: + ds = str(d) + if not ds.startswith("_") and ds not in dims: + dims.append(ds) + return dims + def __repr__(self) -> str: # Collect user-facing dims with sizes (skip internal _ prefixed dims) user_dims: dict[str, int] = {} @@ -163,6 +173,39 @@ def __repr__(self) -> str: return r +def _grouped_names(model: Model) -> tuple[set[str], set[str]]: + """ + Names of auxiliary variables/constraints that belong to a piecewise + formulation. Returned as separate sets because variables and + constraints live in independent namespaces in the model. + """ + var_names: set[str] = set() + con_names: set[str] = set() + for pwl in model._piecewise_formulations.values(): + var_names.update(pwl.variable_names) + con_names.update(pwl.constraint_names) + return var_names, con_names + + +def _repr_summary(model: Model) -> str: + """ + Render the model-level summary of all piecewise formulations. + + Returns the empty string when the model has no formulations so the + caller can unconditionally concatenate. + """ + if not model._piecewise_formulations: + return "" + r = "\nPiecewise Formulations:\n----------------------\n" + for pwl in model._piecewise_formulations.values(): + n_vars = len(pwl.variables) + n_cons = len(pwl.constraints) + user_dims = pwl._user_dims() + dims_str = f" ({', '.join(user_dims)})" if user_dims else "" + r += f" * {pwl.name}{dims_str} — {pwl.method}, {n_vars} vars, {n_cons} cons\n" + return r + + # --------------------------------------------------------------------------- # DataArray construction helpers # --------------------------------------------------------------------------- diff --git a/linopy/variables.py b/linopy/variables.py index 15874029..b03f43dd 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -1510,16 +1510,15 @@ def _format_items(self, exclude: set[str] | None = None) -> str: return r def __repr__(self) -> str: + """ + Return a string representation of the variables container. + """ r = "linopy.model.Variables" line = "-" * len(r) r += f"\n{line}\n" r += self._format_items() return r - def _repr_filtered(self, exclude: set[str]) -> str: - """Format items excluding grouped names (used by Model.__repr__).""" - return self._format_items(exclude) - def __len__(self) -> int: return self.data.__len__() From 31d533f4f1750663b5f854b86ba8b94d3142fc86 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 4 May 2026 17:28:32 +0200 Subject: [PATCH 52/65] feat(piecewise): emit EvolvingAPIWarning once per session A single model build often calls ``add_piecewise_formulation`` / ``tangent_lines`` hundreds of times, and each emit produces a multi-line warning that drowns out other output. Dedup per call site via a module-level set keyed by a typed ``_EvolvingApiKey`` literal so each entry point warns at most once per process. Warning text now mentions the once-per-session behaviour so users know they aren't seeing every call. Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/piecewise.py | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 3a3e6019..4c9cb034 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -59,6 +59,21 @@ logger = logging.getLogger(__name__) +# Each user-facing piecewise entry point fires its EvolvingAPIWarning at +# most once per process. Without dedup, a single model build emits the +# verbose warning hundreds of times and drowns out other output. +_EvolvingApiKey: TypeAlias = Literal["tangent_lines", "add_piecewise_formulation"] +_emitted_evolving_warnings: set[_EvolvingApiKey] = set() + + +def _warn_evolving_api(key: _EvolvingApiKey, message: str) -> None: + """Emit an :class:`EvolvingAPIWarning` at most once per session per ``key``.""" + if key in _emitted_evolving_warnings: + return + _emitted_evolving_warnings.add(key) + warnings.warn(message, category=EvolvingAPIWarning, stacklevel=3) + + # Accepted input types for breakpoint-like data BreaksLike: TypeAlias = ( Sequence[float] | DataArray | pd.Series | pd.DataFrame | dict[str, Sequence[float]] @@ -606,15 +621,15 @@ def tangent_lines( Silence with ``warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)``. """ - warnings.warn( + _warn_evolving_api( + "tangent_lines", "piecewise: tangent_lines is a new API; the returned expression " "shape and the piece-dim name may be refined in minor releases. " "Please share your use cases or concerns at " "https://github.com/PyPSA/linopy/issues — your feedback shapes " - "what stabilises. Silence with " + "what stabilises. This warning fires once per session; silence " + "entirely with " '`warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.', - category=EvolvingAPIWarning, - stacklevel=2, ) return _tangent_lines_impl(x, x_points, y_points) @@ -896,15 +911,15 @@ def add_piecewise_formulation( with ``warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)``. """ - warnings.warn( + _warn_evolving_api( + "add_piecewise_formulation", "piecewise: add_piecewise_formulation is a new API; some details " "(e.g. the per-tuple sign convention, active+sign semantics) " "may be refined in minor releases. Please share your use cases " "or concerns at https://github.com/PyPSA/linopy/issues — your " - "feedback shapes what stabilises. Silence with " + "feedback shapes what stabilises. This warning fires once per " + "session; silence entirely with " '`warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.', - category=EvolvingAPIWarning, - stacklevel=2, ) # Migration helper: explicit error for the removed sign= keyword. From 8a5743754d9888bac6ded97d54659193650a23f2 Mon Sep 17 00:00:00 2001 From: Fabian Date: Mon, 4 May 2026 15:05:18 +0200 Subject: [PATCH 53/65] doc: add more plots to pwl notebook. feat: add jupyter and ipykernel to dev extension in installation --- examples/piecewise-linear-constraints.ipynb | 94 +++++++++++++++++---- pyproject.toml | 3 + 2 files changed, 82 insertions(+), 15 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index be891b85..de475bcb 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -38,12 +38,12 @@ "time = pd.Index([1, 2, 3], name=\"time\")\n", "\n", "\n", - "def plot_curve(bp_x, bp_y, operating_x, operating_y, *, color=\"C0\", ax=None):\n", + "def plot_curve(bp_x, bp_y, operating_x, operating_y, *, ax=None, xlabel=\"power\", ylabel=\"fuel\"):\n", " \"\"\"PWL curve with solver's operating points overlaid.\"\"\"\n", - " ax = ax or plt.subplots(figsize=(4.5, 3.5))[1]\n", - " ax.plot(bp_x, bp_y, \"o-\", color=color, label=\"breakpoints\")\n", - " ax.plot(operating_x, operating_y, \"D\", color=color, ms=10, label=\"solved\")\n", - " ax.set(xlabel=\"power\", ylabel=\"fuel\")\n", + " ax = ax if ax is not None else plt.subplots(figsize=(4.5, 3.5))[1]\n", + " ax.plot(bp_x, bp_y, \"o-\", color=\"C0\", label=\"breakpoints\")\n", + " ax.plot(operating_x, operating_y, \"D\", color=\"C1\", ms=10, label=\"solved\", alpha=0.8)\n", + " ax.set(xlabel=xlabel, ylabel=ylabel)\n", " ax.legend()\n", " return ax" ] @@ -68,14 +68,14 @@ }, "outputs": [], "source": [ - "x_pts = [0, 30, 60, 100]\n", - "y_pts = [0, 36, 84, 170]\n", "demand = xr.DataArray([50, 80, 30], coords=[time])\n", "\n", "m = linopy.Model()\n", "power = m.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", "\n", + "x_pts = [0, 30, 60, 100]\n", + "y_pts = [0, 36, 84, 170]\n", "pwf = m.add_piecewise_formulation((power, x_pts), (fuel, y_pts))\n", "m.add_constraints(power == demand, name=\"demand\")\n", "m.add_objective(fuel.sum())\n", @@ -157,7 +157,8 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:59.092539Z", "start_time": "2026-04-22T23:31:58.956054Z" - } + }, + "scrolled": true }, "outputs": [], "source": [ @@ -186,7 +187,17 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## 4. Inequality bounds — per-tuple sign\n\nAppend a third tuple element (`\"<=\"` or `\">=\"`) to mark a single expression as **bounded** by the piecewise curve instead of pinned to it. The other tuples stay on the curve. The 2-variable hypograph (`y ≤ f(x)`) and epigraph (`y ≥ f(x)`) are the canonical cases.\n\nOn a concave curve with `<=` (or convex with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation — no binaries, no SOS2.\n\nAt most one tuple may carry a non-equality sign. With 3 or more tuples, all signs must be `\"==\"`; the multi-input bounded case is reserved for a future bivariate / triangulated piecewise API.\n\nSee the [inequality bounds notebook](piecewise-inequality-bounds-tutorial) for mismatched curvature, auto-dispatch fallbacks, and more geometry." + "source": [ + "## 4. Inequality bounds — per-tuple sign\n", + "\n", + "Append a third tuple element (`\"<=\"` or `\">=\"`) to mark a single expression as **bounded** by the piecewise curve instead of pinned to it. The other tuples stay on the curve. The 2-variable hypograph (`y ≤ f(x)`) and epigraph (`y ≥ f(x)`) are the canonical cases.\n", + "\n", + "On a concave curve with `<=` (or convex with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation — no binaries, no SOS2.\n", + "\n", + "At most one tuple may carry a non-equality sign. With 3 or more tuples, all signs must be `\"==\"`; the multi-input bounded case is reserved for a future bivariate / triangulated piecewise API.\n", + "\n", + "See the [inequality bounds notebook](piecewise-inequality-bounds-tutorial) for mismatched curvature, auto-dispatch fallbacks, and more geometry." + ] }, { "cell_type": "code", @@ -198,7 +209,35 @@ } }, "outputs": [], - "source": "m = linopy.Model()\npower = m.add_variables(name=\"power\", lower=0, upper=120, coords=[time])\nfuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n\n# concave curve: diminishing marginal fuel per MW\npwf = m.add_piecewise_formulation(\n (fuel, [0, 50, 90, 120], \"<=\"), # bounded above by the curve\n (power, [0, 40, 80, 120]), # pinned to the curve\n)\nm.add_constraints(power == xr.DataArray([30, 80, 100], coords=[time]))\nm.add_objective(-fuel.sum()) # push fuel against the bound\nm.solve(reformulate_sos=\"auto\")\n\nprint(f\"resolved method={pwf.method}, curvature={pwf.convexity}\")\nm.solution[[\"power\", \"fuel\"]].to_pandas()" + "source": [ + "m = linopy.Model()\n", + "power = m.add_variables(name=\"power\", lower=0, upper=120, coords=[time])\n", + "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", + "\n", + "# concave curve: diminishing marginal fuel per MW\n", + "x_pts = [0, 50, 90, 120]\n", + "y_pts = [0, 40, 80, 120]\n", + "pwf = m.add_piecewise_formulation(\n", + " (fuel, x_pts, \"<=\"), # bounded above by the curve\n", + " (power, y_pts), # pinned to the curve\n", + ")\n", + "m.add_constraints(power == xr.DataArray([30, 80, 100], coords=[time]))\n", + "m.add_objective(-fuel.sum()) # push fuel against the bound\n", + "m.solve(reformulate_sos=\"auto\")\n", + "\n", + "print(f\"resolved method={pwf.method}, curvature={pwf.convexity}\")\n", + "m.solution[[\"power\", \"fuel\"]].to_pandas()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# x_pts are fuel breakpoints, y_pts are power breakpoints — swap so power is on the x-axis\n", + "plot_curve(y_pts, x_pts, m.solution[\"power\"].values, m.solution[\"fuel\"].values);" + ] }, { "cell_type": "markdown", @@ -231,9 +270,11 @@ "backup = m.add_variables(name=\"backup\", lower=0, coords=[time])\n", "commit = m.add_variables(name=\"commit\", binary=True, coords=[time])\n", "\n", + "x_pts = [p_min, 60, p_max]\n", + "y_pts = [40, 90, 170]\n", "m.add_piecewise_formulation(\n", - " (power, [p_min, 60, p_max]),\n", - " (fuel, [40, 90, 170]),\n", + " (power, x_pts),\n", + " (fuel, y_pts),\n", " active=commit,\n", ")\n", "# demand below p_min at t=1 — commit must be 0 and backup covers it\n", @@ -243,6 +284,15 @@ "m.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "plot_curve(x_pts, y_pts, m.solution[\"power\"].values, m.solution[\"fuel\"].values);" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -268,10 +318,13 @@ "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", "heat = m.add_variables(name=\"heat\", lower=0, coords=[time])\n", "\n", + "x_pts = [0, 30, 60, 100]\n", + "y_pts = [0, 40, 85, 160]\n", + "z_pts = [0, 25, 55, 95]\n", "m.add_piecewise_formulation(\n", - " (power, [0, 30, 60, 100]),\n", - " (fuel, [0, 40, 85, 160]),\n", - " (heat, [0, 25, 55, 95]),\n", + " (power, x_pts),\n", + " (fuel, y_pts),\n", + " (heat, z_pts),\n", ")\n", "m.add_constraints(fuel == xr.DataArray([20, 100, 160], coords=[time]))\n", "m.add_objective(power.sum())\n", @@ -279,6 +332,17 @@ "m.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas().round(2)" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(1, 2, figsize=(7, 3))\n", + "plot_curve(x_pts, y_pts, m.solution[\"power\"].values, m.solution[\"fuel\"].values, ax=axes[0]);\n", + "plot_curve(x_pts, z_pts, m.solution[\"power\"].values, m.solution[\"heat\"].values, ylabel=\"heat\", ax=axes[1]);" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/pyproject.toml b/pyproject.toml index eb2e05c5..8e78d986 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,9 @@ dev = [ "pytest-cov", 'mypy', "pre-commit", + "ipykernel", + "jupyter", + "matplotlib", "netcdf4", "paramiko", "types-paramiko", From 80fab5c625e74abc665945eba4fd5c98c0d898c9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 15:48:28 +0000 Subject: [PATCH 54/65] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- examples/piecewise-linear-constraints.ipynb | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index de475bcb..737f020b 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -38,7 +38,9 @@ "time = pd.Index([1, 2, 3], name=\"time\")\n", "\n", "\n", - "def plot_curve(bp_x, bp_y, operating_x, operating_y, *, ax=None, xlabel=\"power\", ylabel=\"fuel\"):\n", + "def plot_curve(\n", + " bp_x, bp_y, operating_x, operating_y, *, ax=None, xlabel=\"power\", ylabel=\"fuel\"\n", + "):\n", " \"\"\"PWL curve with solver's operating points overlaid.\"\"\"\n", " ax = ax if ax is not None else plt.subplots(figsize=(4.5, 3.5))[1]\n", " ax.plot(bp_x, bp_y, \"o-\", color=\"C0\", label=\"breakpoints\")\n", @@ -339,8 +341,17 @@ "outputs": [], "source": [ "fig, axes = plt.subplots(1, 2, figsize=(7, 3))\n", - "plot_curve(x_pts, y_pts, m.solution[\"power\"].values, m.solution[\"fuel\"].values, ax=axes[0]);\n", - "plot_curve(x_pts, z_pts, m.solution[\"power\"].values, m.solution[\"heat\"].values, ylabel=\"heat\", ax=axes[1]);" + "plot_curve(\n", + " x_pts, y_pts, m.solution[\"power\"].values, m.solution[\"fuel\"].values, ax=axes[0]\n", + ")\n", + "plot_curve(\n", + " x_pts,\n", + " z_pts,\n", + " m.solution[\"power\"].values,\n", + " m.solution[\"heat\"].values,\n", + " ylabel=\"heat\",\n", + " ax=axes[1],\n", + ");" ] }, { From 3a9c445af323f71b70cd4c6fd4839dd79a992c97 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 4 May 2026 23:17:39 +0200 Subject: [PATCH 55/65] fix(deps): drop notebook deps from [dev] extra to fix CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8a57437 added ipykernel/jupyter/matplotlib to [dev], which transitively pulled requests in via the jupyter metapackage. test/remote/test_oetc.py guards itself with `pytest.importorskip("requests")` — on master that skipped the whole file, but with requests now resolved the file got collected and every test failed at OetcHandler() instantiation because google-cloud-storage (the other half of the [oetc] extra) is still absent. Result: 23 failures + 14 errors across the entire test matrix. Notebook execution lives in test-notebooks.yml, which already installs [docs] (ipykernel + matplotlib are there). No CI job needs notebook deps in [dev]. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8e78d986..eb2e05c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,9 +69,6 @@ dev = [ "pytest-cov", 'mypy', "pre-commit", - "ipykernel", - "jupyter", - "matplotlib", "netcdf4", "paramiko", "types-paramiko", From 774da20a8a14645304c09f5e661d9e69788f956f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 5 May 2026 15:06:37 +0200 Subject: [PATCH 56/65] =?UTF-8?q?refactor(piecewise):=20API=20hygiene=20?= =?UTF-8?q?=E2=80=94=20alphabetise=20=5F=5Fall=5F=5F,=20type=20method,=20d?= =?UTF-8?q?rop=20sign=3D=20migration=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ``__all__`` in ``linopy/__init__.py`` is now sorted (Constraint…tangent_lines). * ``PiecewiseFormulation.method`` typed as ``PWL_METHOD`` instead of ``str``, matching ``convexity``. * Drop ``**kwargs: object`` from ``add_piecewise_formulation``. The pre-release ``sign=`` migration helper and the catch-all unknown-kwarg error were dev backwards-compat for an unreleased API; Python's default TypeError on unexpected kwargs covers the rest. * Extract ``_user_dims_with_sizes`` to share the dim-collection loop between ``_user_dims`` and ``__repr__``. * Loop the two identical ``BREAKPOINT_DIM`` checks in ``_validate_breakpoint_shapes``. Removes ``test_old_sign_kwarg_raises_with_migration_help`` (covered the removed migration helper). Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/__init__.py | 8 ++-- linopy/piecewise.py | 62 +++++++++++------------------- test/test_piecewise_constraints.py | 7 ---- 3 files changed, 27 insertions(+), 50 deletions(-) diff --git a/linopy/__init__.py b/linopy/__init__.py index 09c23b7e..220eee3c 100644 --- a/linopy/__init__.py +++ b/linopy/__init__.py @@ -55,13 +55,13 @@ "RemoteHandler", "Variable", "Variables", + "align", "available_solvers", "breakpoints", - "tangent_lines", - "segments", - "slopes_to_points", - "align", "merge", "options", "read_netcdf", + "segments", + "slopes_to_points", + "tangent_lines", ) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 4c9cb034..3d4fd3c9 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -128,7 +128,7 @@ class PiecewiseFormulation: def __init__( self, name: str, - method: str, + method: PWL_METHOD, variable_names: list[str], constraint_names: list[str], model: Model, @@ -151,24 +151,28 @@ def constraints(self) -> Constraints: """View of the auxiliary constraints in this formulation.""" return self._model.constraints[self.constraint_names] - def _user_dims(self) -> list[str]: - """User-facing dim names across this formulation's auxiliary variables.""" - dims: list[str] = [] + def _user_dims_with_sizes(self) -> dict[str, int]: + """ + User-facing dims across the formulation's variables, with sizes. + + Skips internal ``_``-prefixed dims (e.g. ``_pwl_var``). Insertion + order is preserved, so callers can use the keys as a stable + ordered list. + """ + dims: dict[str, int] = {} for var in self.variables.data.values(): for d in var.coords: ds = str(d) if not ds.startswith("_") and ds not in dims: - dims.append(ds) + dims[ds] = var.data.sizes[d] return dims + def _user_dims(self) -> list[str]: + """User-facing dim names across this formulation's auxiliary variables.""" + return list(self._user_dims_with_sizes()) + def __repr__(self) -> str: - # Collect user-facing dims with sizes (skip internal _ prefixed dims) - user_dims: dict[str, int] = {} - for var in self.variables.data.values(): - for d in var.coords: - ds = str(d) - if not ds.startswith("_") and ds not in user_dims: - user_dims[ds] = var.data.sizes[d] + user_dims = self._user_dims_with_sizes() dims_str = ", ".join(f"{d}: {s}" for d, s in user_dims.items()) header = f"PiecewiseFormulation `{self.name}`" if dims_str: @@ -645,18 +649,13 @@ def _validate_breakpoint_shapes(bp_a: DataArray, bp_b: DataArray) -> bool: Returns whether the formulation is disjunctive (has segment dimension). """ - if BREAKPOINT_DIM not in bp_a.dims: - raise ValueError( - 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 bp_b.dims: - raise ValueError( - f"Breakpoints are missing the '{BREAKPOINT_DIM}' dimension, " - f"got dims {list(bp_b.dims)}. " - "Use the breakpoints() or segments() factory." - ) + for bp in (bp_a, bp_b): + if BREAKPOINT_DIM not in bp.dims: + raise ValueError( + f"Breakpoints are missing the '{BREAKPOINT_DIM}' dimension, " + f"got dims {list(bp.dims)}. " + "Use the breakpoints() or segments() factory." + ) if bp_a.sizes[BREAKPOINT_DIM] != bp_b.sizes[BREAKPOINT_DIM]: raise ValueError( @@ -801,7 +800,6 @@ def add_piecewise_formulation( method: PWL_METHOD = "auto", active: LinExprLike | None = None, name: str | None = None, - **kwargs: object, ) -> PiecewiseFormulation: r""" Add piecewise linear constraints. @@ -922,20 +920,6 @@ def add_piecewise_formulation( '`warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.', ) - # Migration helper: explicit error for the removed sign= keyword. - if "sign" in kwargs: - raise TypeError( - "The `sign=` keyword has been removed from add_piecewise_formulation. " - "Specify the sign per-tuple as a third tuple element, e.g. " - "`(fuel, y_pts, '<=')` instead of `sign='<='`. " - "See doc/piecewise-linear-constraints.rst." - ) - if kwargs: - raise TypeError( - "add_piecewise_formulation() got unexpected keyword argument(s): " - f"{sorted(kwargs)}" - ) - if method not in PWL_METHODS: raise ValueError(f"method must be one of {sorted(PWL_METHODS)}, got '{method}'") diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index fc7ace5f..32a77dcd 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -1477,13 +1477,6 @@ def test_invalid_per_tuple_sign_raises(self) -> None: with pytest.raises(ValueError, match="sign must be"): m.add_piecewise_formulation((x, [0, 10], "!"), (y, [0, 5])) # type: ignore - def test_old_sign_kwarg_raises_with_migration_help(self) -> None: - m = Model() - x = m.add_variables(name="x") - y = m.add_variables(name="y") - with pytest.raises(TypeError, match="sign=.*has been removed"): - m.add_piecewise_formulation((x, [0, 10]), (y, [0, 5]), sign="<=") - def test_two_bounded_tuples_raises(self) -> None: m = Model() x = m.add_variables(name="x") From 6ecb7c18c23f2787afbbc4e6fa5c3ca85c64a118 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 5 May 2026 15:07:47 +0200 Subject: [PATCH 57/65] refactor(piecewise): single source of truth for LP eligibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both ``_lp_eligibility`` (auto-dispatch) and ``_try_lp`` (explicit ``method='lp'``) re-implemented the same five checks (n_tuples, is_equality, active, monotonicity, sign+curvature) — the latter just raised instead of returning a reason. ``_try_lp`` now always calls ``_lp_eligibility`` and translates the ``(False, reason)`` result into either an INFO log (auto) or a ``ValueError`` (explicit lp), so adding a new eligibility rule means editing one function instead of two. The raised-error wording is slightly more uniform — the eligibility ``reason`` is now embedded verbatim — but every existing assertion matches. Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/piecewise.py | 69 +++++++++++++++------------------------------ 1 file changed, 23 insertions(+), 46 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 3d4fd3c9..110bb180 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -1259,60 +1259,37 @@ def _try_lp( active: LinearExpression | None, ) -> bool: """Dispatch the LP formulation if requested or eligible.""" - if method == "lp": - if inputs.n_tuples != 2: - raise ValueError( - "method='lp' requires exactly 2 (expression, breakpoints[, sign]) pairs." - ) - if inputs.is_equality: - raise ValueError("method='lp' requires one tuple with sign='<=' or '>='.") - if active is not None: - raise ValueError("method='lp' is not compatible with active=...") - assert inputs.bounded_bp is not None # narrowed by is_equality check - assert inputs.bounded_expr is not None - x_pts = inputs.pinned_bps[0] - y_pts = inputs.bounded_bp - if not _check_strict_monotonicity(x_pts): - raise ValueError("method='lp' requires strictly monotonic x breakpoints.") - convexity = _detect_convexity(x_pts, y_pts) - sign = inputs.bounded_sign - if sign == LESS_EQUAL and convexity not in ("concave", "linear"): - raise ValueError( - "method='lp' with sign='<=' requires concave or linear " - f"curvature; got '{convexity}'. Use method='auto'." - ) - if sign == GREATER_EQUAL and convexity not in ("convex", "linear"): - raise ValueError( - "method='lp' with sign='>=' requires convex or linear " - f"curvature; got '{convexity}'. Use method='auto'." - ) - _add_lp( - model, name, inputs.pinned_exprs[0], inputs.bounded_expr, x_pts, y_pts, sign - ) - return True + if method not in ("lp", "auto"): + return False + if method == "auto" and inputs.is_equality: + return False - if method == "auto" and not inputs.is_equality: - ok, reason = _lp_eligibility(inputs, active) - if ok: - assert inputs.bounded_expr is not None - assert inputs.bounded_bp is not None - _add_lp( - model, - name, - inputs.pinned_exprs[0], - inputs.bounded_expr, - inputs.pinned_bps[0], - inputs.bounded_bp, - inputs.bounded_sign, + ok, reason = _lp_eligibility(inputs, active) + if not ok: + if method == "lp": + raise ValueError( + f"method='lp' is not applicable: {reason}. Use method='auto'." ) - return True logger.info( "piecewise formulation '%s': LP not applicable (%s); " "will use SOS2/incremental instead", name, reason, ) - return False + return False + + assert inputs.bounded_expr is not None + assert inputs.bounded_bp is not None + _add_lp( + model, + name, + inputs.pinned_exprs[0], + inputs.bounded_expr, + inputs.pinned_bps[0], + inputs.bounded_bp, + inputs.bounded_sign, + ) + return True def _resolve_sos2_vs_incremental(method: str, stacked_bp: DataArray) -> str: From 07e0dab34e3dbfb6004cade073ccab3017f82f94 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 5 May 2026 15:08:07 +0200 Subject: [PATCH 58/65] fix(io): require ``convexity`` field on netCDF reload The ``to_netcdf`` writer always emits ``convexity`` (possibly ``None``) for every persisted ``PiecewiseFormulation``, so the reader's ``d.get("convexity")`` was masking what is actually a required field. Switching to ``d["convexity"]`` makes the schema mismatch explicit and matches the fail-fast posture of every other key in the same loop (``method``, ``variable_names``, ``constraint_names``). Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linopy/io.py b/linopy/io.py index 8587dfa2..24ba4303 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -1266,7 +1266,7 @@ def get_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: variable_names=d["variable_names"], constraint_names=d["constraint_names"], model=m, - convexity=d.get("convexity"), + convexity=d["convexity"], ) return m From b1849873589ab9744e9a3fcacfc1e9e7c073de13 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 5 May 2026 15:16:27 +0200 Subject: [PATCH 59/65] test(piecewise): cover EvolvingAPIWarning, formulation API, LP eligibility, more netCDF cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes review gaps left by #638: * ``TestEvolvingAPIWarning`` — first-call fires, dedup holds across repeats, ``tangent_lines`` and ``add_piecewise_formulation`` dedup independently, ``stacklevel=3`` reports the user's call site. Module-global dedup set is cleared by an autouse fixture so test order doesn't matter. * ``TestPiecewiseFormulationAPI`` — the ``.variables`` / ``.constraints`` properties and ``__repr__`` were essentially unexercised; smoke-tests for the equality and LP shapes (the latter has empty ``variable_names``). * ``TestLPEligibilityReasons`` — direct unit test of ``_lp_eligibility`` with handcrafted ``_PwlInputs``, parametrised over each branch (too many tuples / all equality / active / non-monotonic / wrong-curvature ``<=`` and ``>=``) plus the success path. Uses ``_PwlInputs`` directly so branches that the public-API short-circuits hide are still covered. * ``TestPiecewiseNetCDFRoundtrip`` — parametrised over three shapes: the existing 2-var equality, plus a bounded ``<=``/LP case (empty ``variable_names``) and a 3-var equality (``convexity is None``), so the roundtrip exercises every reachable combination of persisted fields. Asserts the reloaded ``.variables`` / ``.constraints`` properties work, catching any future regression where the model back-reference isn't rebound. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/test_piecewise_constraints.py | 350 ++++++++++++++++++++++++++++- 1 file changed, 339 insertions(+), 11 deletions(-) diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 32a77dcd..e080c8be 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import warnings from pathlib import Path from typing import Literal, TypeAlias @@ -2038,23 +2039,57 @@ def test_multi_entity_mixed_curvatures(self) -> None: class TestPiecewiseNetCDFRoundtrip: - def test_formulation_survives_netcdf(self, tmp_path: Path) -> None: + """ + Each case exercises a different combination of persisted fields: + + * ``equality_2var`` — non-empty ``variable_names`` + ``convexity != None`` + * ``bounded_lp`` — empty ``variable_names`` (LP path) + + ``convexity != None`` (the only path that yields ``method='lp'``) + * ``equality_3var`` — non-empty ``variable_names`` + ``convexity is None`` + (3-var formulations don't classify curvature) + """ + + def _build(self, kind: str) -> tuple[Model, str]: + m = Model() + if kind == "equality_2var": + y = m.add_variables(name="y") + x = m.add_variables(lower=0, upper=30, name="x") + m.add_piecewise_formulation( + (y, [0, 20, 30, 35]), (x, [0, 10, 20, 30]), name="pwl" + ) + elif kind == "bounded_lp": + x = m.add_variables(lower=0, upper=30, name="x") + y = m.add_variables(lower=0, upper=40, name="y") + m.add_piecewise_formulation( + (y, [0, 20, 30, 35], "<="), + (x, [0, 10, 20, 30]), + name="pwl", + ) + elif kind == "equality_3var": + x = m.add_variables(name="x") + y = m.add_variables(name="y") + z = m.add_variables(name="z") + m.add_piecewise_formulation( + (x, [0, 30, 60, 100]), + (y, [0, 40, 85, 160]), + (z, [0, 25, 55, 95]), + name="pwl", + ) + else: + raise ValueError(kind) + return m, "pwl" + + @pytest.mark.parametrize("kind", ["equality_2var", "bounded_lp", "equality_3var"]) + def test_formulation_survives_netcdf(self, tmp_path: Path, kind: str) -> None: from linopy import read_netcdf from linopy.piecewise import PiecewiseFormulation - m = Model() - y = m.add_variables(name="y") - x = m.add_variables(lower=0, upper=30, name="x") - f = m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), - (x, [0, 10, 20, 30]), - name="pwl", - ) - assert f.convexity == "concave" + m, name = self._build(kind) + f = m._piecewise_formulations[name] path = tmp_path / "model.nc" m.to_netcdf(path) - f2 = read_netcdf(path)._piecewise_formulations["pwl"] + f2 = read_netcdf(path)._piecewise_formulations[name] # Compare every slot except the back-reference to the model, so this # test auto-catches any future field that IO forgets to persist. @@ -2062,3 +2097,296 @@ def test_formulation_survives_netcdf(self, tmp_path: Path) -> None: before = {s: getattr(f, s) for s in fields} after = {s: getattr(f2, s) for s in fields} assert before == after + + # The reloaded formulation's properties must work — i.e. the model + # back-reference was rebound and the named members exist. + assert list(f2.variables) == list(f.variables) + assert list(f2.constraints) == list(f.constraints) + + +# =========================================================================== +# PiecewiseFormulation API surface +# =========================================================================== + + +class TestPiecewiseFormulationAPI: + def test_variables_constraints_repr(self) -> None: + m = Model() + y = m.add_variables(name="y") + x = m.add_variables(lower=0, upper=30, name="x") + f = m.add_piecewise_formulation( + (y, [0, 20, 30, 35]), + (x, [0, 10, 20, 30]), + name="pwl", + ) + # Properties return live views from the model. + assert set(f.variables) == set(f.variable_names) + assert set(f.constraints) == set(f.constraint_names) + + # __repr__ at minimum names the formulation, the resolved method, + # and lists each generated variable / constraint by name. + r = repr(f) + assert "pwl" in r + assert f.method in r + for vname in f.variable_names: + assert vname in r + for cname in f.constraint_names: + assert cname in r + + def test_repr_lp_has_no_variables_section_entries(self) -> None: + """LP formulation has zero auxiliary variables; __repr__ must still render.""" + m = Model() + x = m.add_variables(lower=0, upper=30, name="x") + y = m.add_variables(lower=0, upper=40, name="y") + f = m.add_piecewise_formulation( + (y, [0, 20, 30, 35], "<="), + (x, [0, 10, 20, 30]), + name="pwl_lp", + ) + assert f.method == "lp" + assert f.variable_names == [] + r = repr(f) + assert "pwl_lp" in r + assert "lp" in r + + +# =========================================================================== +# _lp_eligibility — each branch's user-facing reason string +# =========================================================================== + + +class TestLPEligibilityReasons: + """ + Pin the diagnostic returned by each branch of ``_lp_eligibility``. + These reason strings flow into both the auto-dispatch INFO log and the + explicit ``method='lp'`` ``ValueError``, so each is a piece of the + user-facing API. + + Direct unit test (rather than via ``add_piecewise_formulation``) so the + branches that the public-API short-circuits hide are still covered. + """ + + @staticmethod + def _make_inputs( + x_pts: list[float], + y_pts: list[float], + sign: str, + n_pinned: int = 1, + ) -> object: + from xarray import DataArray + + from linopy.constants import BREAKPOINT_DIM, EQUAL, sign_replace_dict + from linopy.piecewise import _PwlInputs + + # Normalise per the production code so callers can write "==" instead + # of "=" in the parametrize table. + sign = sign_replace_dict.get(sign, sign) + + m = Model() + x = m.add_variables(name=f"x_{id(x_pts)}") + pinned_exprs = [(x * 1.0)] + pinned_bps = [ + DataArray(x_pts, dims=[BREAKPOINT_DIM], coords={BREAKPOINT_DIM: x_pts}) + ] + pinned_coords = ["x"] + for i in range(1, n_pinned): + xi = m.add_variables(name=f"x{i}_{id(x_pts)}") + pinned_exprs.append(xi * 1.0) + pinned_bps.append(pinned_bps[0]) + pinned_coords.append(f"x{i}") + + if sign == EQUAL: + return _PwlInputs( + pinned_exprs=pinned_exprs, + pinned_bps=pinned_bps, + pinned_coords=pinned_coords, + bounded_expr=None, + bounded_bp=None, + bounded_coord=None, + bounded_sign=EQUAL, + bp_mask=None, + ) + + y = m.add_variables(name=f"y_{id(y_pts)}") + return _PwlInputs( + pinned_exprs=pinned_exprs, + pinned_bps=pinned_bps, + pinned_coords=pinned_coords, + bounded_expr=y * 1.0, + bounded_bp=DataArray( + y_pts, dims=[BREAKPOINT_DIM], coords={BREAKPOINT_DIM: x_pts} + ), + bounded_coord="y", + bounded_sign=sign, + bp_mask=None, + ) + + @pytest.mark.parametrize( + ("kwargs", "active", "fragments"), + [ + pytest.param( + { + "x_pts": [0.0, 10.0], + "y_pts": [0.0, 5.0], + "sign": "==", + "n_pinned": 3, + }, + None, + ("3 expressions", "LP supports only 2"), + id="too_many_tuples", + ), + pytest.param( + { + "x_pts": [0.0, 10.0], + "y_pts": [0.0, 5.0], + "sign": "==", + "n_pinned": 2, + }, + None, + ("all tuples are equality",), + id="all_equality", + ), + pytest.param( + { + "x_pts": [0.0, 10.0, 20.0, 30.0], + "y_pts": [0.0, 20.0, 30.0, 35.0], + "sign": "<=", + }, + "stub", + ("active",), + id="active_present", + ), + pytest.param( + { + "x_pts": [0.0, 20.0, 10.0, 30.0], + "y_pts": [0.0, 20.0, 30.0, 35.0], + "sign": "<=", + }, + None, + ("not strictly monotonic",), + id="non_monotonic_x", + ), + pytest.param( + { + "x_pts": [0.0, 10.0, 20.0, 30.0], + "y_pts": [0.0, 5.0, 15.0, 30.0], + "sign": "<=", + }, + None, + ("sign='<='", "concave"), + id="le_on_convex", + ), + pytest.param( + { + "x_pts": [0.0, 10.0, 20.0, 30.0], + "y_pts": [0.0, 20.0, 30.0, 35.0], + "sign": ">=", + }, + None, + ("sign='>='", "convex"), + id="ge_on_concave", + ), + ], + ) + def test_reason_string( + self, + kwargs: dict, # type: ignore[type-arg] + active: object, + fragments: tuple[str, ...], + ) -> None: + from linopy.piecewise import _lp_eligibility + + inputs = self._make_inputs(**kwargs) + # ``active`` carrying the literal "stub" string is a sentinel — the + # eligibility check only looks at ``is None`` vs not, so any non-None + # value triggers the rejection branch. + ok, reason = _lp_eligibility(inputs, active) # type: ignore[arg-type] + assert not ok + for frag in fragments: + assert frag in reason, f"missing {frag!r} in reason: {reason!r}" + + def test_eligible_concave_le_returns_ok(self) -> None: + from linopy.piecewise import _lp_eligibility + + inputs = self._make_inputs( + x_pts=[0.0, 10.0, 20.0, 30.0], + y_pts=[0.0, 20.0, 30.0, 35.0], # concave + sign="<=", + ) + ok, reason = _lp_eligibility(inputs, None) + assert ok + assert reason == "" + + +# =========================================================================== +# EvolvingAPIWarning — fires once per session per entry point +# =========================================================================== + + +class TestEvolvingAPIWarning: + @pytest.fixture(autouse=True) + def _reset_dedup(self) -> None: + """ + Warnings dedup is module-global so order between tests would + otherwise matter. Clear before each test. + """ + from linopy.piecewise import _emitted_evolving_warnings + + _emitted_evolving_warnings.clear() + yield + _emitted_evolving_warnings.clear() + + def test_add_piecewise_formulation_warns_first_call(self) -> None: + from linopy import EvolvingAPIWarning + + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + with pytest.warns(EvolvingAPIWarning, match="add_piecewise_formulation"): + m.add_piecewise_formulation((x, [0, 10]), (y, [0, 5])) + + def test_add_piecewise_formulation_dedups(self) -> None: + from linopy import EvolvingAPIWarning + + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", EvolvingAPIWarning) + m.add_piecewise_formulation((x, [0, 10]), (y, [0, 5])) + m.add_piecewise_formulation((x, [0, 10]), (y, [0, 5])) + m.add_piecewise_formulation((x, [0, 10]), (y, [0, 5])) + evolving = [w for w in caught if issubclass(w.category, EvolvingAPIWarning)] + assert len(evolving) == 1 + + def test_tangent_lines_warns_and_dedups_independently(self) -> None: + from linopy import EvolvingAPIWarning + + m = Model() + x = m.add_variables(lower=0, upper=10, name="x") + x_pts = [0.0, 5.0, 10.0] + y_pts = [0.0, 4.0, 5.0] + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", EvolvingAPIWarning) + tangent_lines(x, x_pts, y_pts) + tangent_lines(x, x_pts, y_pts) + evolving = [w for w in caught if issubclass(w.category, EvolvingAPIWarning)] + assert len(evolving) == 1 + assert "tangent_lines" in str(evolving[0].message) + + def test_warning_stacklevel_points_to_user_call(self) -> None: + """ + ``stacklevel=3`` in ``_warn_evolving_api`` should make the warning + report this test file as the source, not the internal helper. + """ + from linopy import EvolvingAPIWarning + + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", EvolvingAPIWarning) + m.add_piecewise_formulation((x, [0, 10]), (y, [0, 5])) + evolving = [w for w in caught if issubclass(w.category, EvolvingAPIWarning)] + assert len(evolving) == 1 + assert evolving[0].filename.endswith("test_piecewise_constraints.py") From 2a447c2204d77cb182b6a71ce177901ebfbd6697 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 5 May 2026 15:30:48 +0200 Subject: [PATCH 60/65] refactor(piecewise): convert PiecewiseFormulation to @dataclass(slots=True) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hand-rolled ``__slots__`` + ``__init__`` boilerplate with ``@dataclass(slots=True, repr=False)`` (the custom ``__repr__`` is preserved). Net 17 lines removed. Also renames the back-reference field from ``_model`` to ``model``. The underscore was protective of an attribute that's actually a fine read: ``pwf.model`` is a sensible public access. This also lets the dataclass init signature stay ``model=...`` without aliasing tricks — the two existing callers (``add_piecewise_formulation`` and the netCDF reload in ``io.py``) already used ``model=`` keyword and don't need any change. Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/piecewise.py | 35 ++++++++---------------------- test/test_piecewise_constraints.py | 2 +- 2 files changed, 10 insertions(+), 27 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 110bb180..2a4b868b 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -93,6 +93,7 @@ def _warn_evolving_api(key: _EvolvingApiKey, message: str) -> None: # --------------------------------------------------------------------------- +@dataclass(slots=True, repr=False) class PiecewiseFormulation: """ Result of ``add_piecewise_formulation``. @@ -116,40 +117,22 @@ class PiecewiseFormulation: monotonic ``x`` breakpoints). ``None`` otherwise. """ - __slots__ = ( - "name", - "method", - "convexity", - "variable_names", - "constraint_names", - "_model", - ) - - def __init__( - self, - name: str, - method: PWL_METHOD, - variable_names: list[str], - constraint_names: list[str], - model: Model, - convexity: PWL_CONVEXITY | None = None, - ) -> None: - self.name = name - self.method = method - self.convexity = convexity - self.variable_names = variable_names - self.constraint_names = constraint_names - self._model = model + name: str + method: PWL_METHOD + variable_names: list[str] + constraint_names: list[str] + model: Model + convexity: PWL_CONVEXITY | None = None @property def variables(self) -> Variables: """View of the auxiliary variables in this formulation.""" - return self._model.variables[self.variable_names] + return self.model.variables[self.variable_names] @property def constraints(self) -> Constraints: """View of the auxiliary constraints in this formulation.""" - return self._model.constraints[self.constraint_names] + return self.model.constraints[self.constraint_names] def _user_dims_with_sizes(self) -> dict[str, int]: """ diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index e080c8be..71fce4a9 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -2093,7 +2093,7 @@ def test_formulation_survives_netcdf(self, tmp_path: Path, kind: str) -> None: # Compare every slot except the back-reference to the model, so this # test auto-catches any future field that IO forgets to persist. - fields = [s for s in PiecewiseFormulation.__slots__ if s != "_model"] + fields = [s for s in PiecewiseFormulation.__slots__ if s != "model"] before = {s: getattr(f, s) for s in fields} after = {s: getattr(f2, s) for s in fields} assert before == after From 77409fe40ebbec1e8d0982e0b56d67b21bb3a75f Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 5 May 2026 15:55:09 +0200 Subject: [PATCH 61/65] refactor(piecewise): low-friction cleanups from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Promote ``_pwl_var`` to ``PWL_LINK_DIM`` constant and reuse ``LP_PIECE_DIM`` in ``_add_lp`` instead of recomputing ``f"{dim}_piece"`` — one source of truth for each magic dim name. * Tighten ``_resolve_sos2_vs_incremental`` and ``_add_continuous`` return types to ``Literal`` / ``PWL_METHOD`` so the chain producing ``PiecewiseFormulation.method`` is type-checked end-to-end. * Drop the dead ``raise ValueError(f"unknown method ...")`` branch in ``_resolve_sos2_vs_incremental`` — ``add_piecewise_formulation`` validates against ``PWL_METHODS`` upstream, and ``_try_lp`` already consumed ``"lp"`` before this is called. Replaced with an ``assert``. * ``PWL_METHODS`` / ``PWL_CONVEXITIES`` switched to ``frozenset`` to signal immutability of the value sets. * ``_strip_nan`` switched to ``arr[~np.isnan(arr)].tolist()`` — vectorised, type-consistent regardless of input (sequence or ``ndarray``). * ``_repr_summary`` uses ``len(pwl.variable_names)`` / ``len(pwl.constraint_names)`` instead of ``len(pwl.variables)`` — the latter constructs a Variables view just to ask its length. * Link-coord fallback for unnamed expressions is now ``f"_pwl_{i}"`` instead of ``str(i)``, so a user variable named e.g. ``"1"`` can't collide with the synthetic coord. * ``variables.py:Variable.__eq__`` ``# type: ignore`` narrowed back to ``# type: ignore[override]`` — the bare form was a regression. Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/constants.py | 5 +++-- linopy/piecewise.py | 46 +++++++++++++++++++++++---------------------- linopy/variables.py | 2 +- 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/linopy/constants.py b/linopy/constants.py index 94bdb1a0..215d6f9e 100644 --- a/linopy/constants.py +++ b/linopy/constants.py @@ -55,12 +55,13 @@ PWL_DOMAIN_HI_SUFFIX = "_domain_hi" PWL_METHOD: TypeAlias = Literal["sos2", "lp", "incremental", "auto"] -PWL_METHODS: set[str] = set(get_args(PWL_METHOD)) +PWL_METHODS: frozenset[str] = frozenset(get_args(PWL_METHOD)) PWL_CONVEXITY: TypeAlias = Literal["convex", "concave", "linear", "mixed"] -PWL_CONVEXITIES: set[str] = set(get_args(PWL_CONVEXITY)) +PWL_CONVEXITIES: frozenset[str] = frozenset(get_args(PWL_CONVEXITY)) BREAKPOINT_DIM = "_breakpoint" SEGMENT_DIM = "_segment" LP_PIECE_DIM = f"{BREAKPOINT_DIM}_piece" +PWL_LINK_DIM = "_pwl_var" GROUPED_TERM_DIM = "_grouped_term" GROUP_DIM = "_group" FACTOR_DIM = "_factor" diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 2a4b868b..dd0ebb4e 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -37,6 +37,7 @@ PWL_DOMAIN_LO_SUFFIX, PWL_FILL_ORDER_SUFFIX, PWL_LAMBDA_SUFFIX, + PWL_LINK_DIM, PWL_LINK_SUFFIX, PWL_METHOD, PWL_METHODS, @@ -160,7 +161,7 @@ def __repr__(self) -> str: header = f"PiecewiseFormulation `{self.name}`" if dims_str: header += f" [{dims_str}]" - suffix = self.method + suffix: str = self.method if self.convexity is not None: suffix += f", {self.convexity}" r = f"{header} — {suffix}\n" @@ -200,8 +201,8 @@ def _repr_summary(model: Model) -> str: return "" r = "\nPiecewise Formulations:\n----------------------\n" for pwl in model._piecewise_formulations.values(): - n_vars = len(pwl.variables) - n_cons = len(pwl.constraints) + n_vars = len(pwl.variable_names) + n_cons = len(pwl.constraint_names) user_dims = pwl._user_dims() dims_str = f" ({', '.join(user_dims)})" if user_dims else "" r += f" * {pwl.name}{dims_str} — {pwl.method}, {n_vars} vars, {n_cons} cons\n" @@ -215,7 +216,8 @@ def _repr_summary(model: Model) -> str: 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)] + arr = np.asarray(vals, dtype=float) + return arr[~np.isnan(arr)].tolist() def _rename_to_pieces(da: DataArray, piece_index: np.ndarray) -> DataArray: @@ -997,7 +999,9 @@ def add_piecewise_formulation( if isinstance(expr, Variable) and expr.name: link_coords.append(expr.name) else: - link_coords.append(str(i)) + # Internal-prefixed fallback so a user variable named e.g. "1" + # can't collide with the synthetic coord for an unnamed expr. + link_coords.append(f"_pwl_{i}") lin_exprs = [_to_linexpr(expr) for expr in raw_exprs] active_expr = _to_linexpr(active) if active is not None else None @@ -1038,7 +1042,7 @@ def add_piecewise_formulation( "method='lp' is not supported for disjunctive (segment) breakpoints" ) _add_disjunctive(model, name, inputs, active_expr) - resolved_method = "sos2" + resolved_method: PWL_METHOD = "sos2" else: resolved_method = _add_continuous(model, name, inputs, method, active_expr) @@ -1110,7 +1114,7 @@ class _PwlInputs: bounded_coord: str | None bounded_sign: str bp_mask: DataArray | None - link_dim: str = "_pwl_var" + link_dim: str = PWL_LINK_DIM @property def is_equality(self) -> bool: @@ -1275,7 +1279,9 @@ def _try_lp( return True -def _resolve_sos2_vs_incremental(method: str, stacked_bp: DataArray) -> str: +def _resolve_sos2_vs_incremental( + method: str, stacked_bp: DataArray +) -> Literal["incremental", "sos2"]: """ Validate and (for ``method="auto"``) pick between SOS2 and incremental based on monotonicity and NaN layout. @@ -1297,15 +1303,11 @@ def _resolve_sos2_vs_incremental(method: str, stacked_bp: DataArray) -> str: ) return "incremental" - if method == "sos2": - _validate_numeric_breakpoint_coords(stacked_bp) - if not trailing_nan_only: - raise ValueError( - "SOS2 method does not support non-trailing NaN breakpoints." - ) - return "sos2" - - raise ValueError(f"unknown method {method!r}") + assert method == "sos2" + _validate_numeric_breakpoint_coords(stacked_bp) + if not trailing_nan_only: + raise ValueError("SOS2 method does not support non-trailing NaN breakpoints.") + return "sos2" def _add_continuous( @@ -1314,20 +1316,20 @@ def _add_continuous( inputs: _PwlInputs, method: str, active: LinearExpression | None = None, -) -> str: +) -> PWL_METHOD: """Returns the resolved method name (``"lp"``, ``"sos2"``, ``"incremental"``).""" if _try_lp(model, name, inputs, method, active): return "lp" links = _build_links(model, inputs) - method = _resolve_sos2_vs_incremental(method, links.stacked_bp) + resolved = _resolve_sos2_vs_incremental(method, links.stacked_bp) - if method == "sos2": + if resolved == "sos2": rhs = active if active is not None else 1 _add_sos2(model, name, links, rhs) else: _add_incremental(model, name, links, active) - return method + return resolved def _add_sos2( @@ -1389,7 +1391,7 @@ def _add_incremental( extra = _var_coords_from(stacked_bp, exclude={dim, links.link_dim}) n_pieces = stacked_bp.sizes[dim] - 1 - piece_dim = f"{dim}_piece" + piece_dim = LP_PIECE_DIM piece_index = pd.Index(range(n_pieces), name=piece_dim) delta_coords = extra + [piece_index] diff --git a/linopy/variables.py b/linopy/variables.py index b03f43dd..1fd3ab4a 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: # type: ignore + def __eq__(self, other: SideLike) -> Constraint: # type: ignore[override] return self.to_linexpr().__eq__(other) def __gt__(self, other: Any) -> NotImplementedType: From dca789155d0f4773310150f12270f9f2b8f4de78 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 5 May 2026 15:57:24 +0200 Subject: [PATCH 62/65] fix(test): mypy clean up in TestLPEligibilityReasons / TestEvolvingAPIWarning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ``_make_inputs`` return type narrowed from ``object`` to ``"_PwlInputs"`` (with ``TYPE_CHECKING`` import) so callers don't need ``# type: ignore`` to dispatch on it. * ``test_reason_string`` ``kwargs: dict[str, Any]`` instead of bare ``dict`` — the previous ``# type: ignore[type-arg]`` is unused under the project's mypy config. * ``_reset_dedup`` autouse fixture annotated as ``Generator[None, None, None]`` since it ``yield``s. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/test_piecewise_constraints.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 71fce4a9..9674fcb1 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -4,8 +4,9 @@ import logging import warnings +from collections.abc import Generator from pathlib import Path -from typing import Literal, TypeAlias +from typing import TYPE_CHECKING, Any, Literal, TypeAlias import numpy as np import pandas as pd @@ -42,6 +43,9 @@ ) from linopy.solver_capabilities import SolverFeature, get_available_solvers_with_feature +if TYPE_CHECKING: + from linopy.piecewise import _PwlInputs + Sign: TypeAlias = Literal["==", "<=", ">="] Method: TypeAlias = Literal["sos2", "incremental", "lp", "auto"] @@ -2172,7 +2176,7 @@ def _make_inputs( y_pts: list[float], sign: str, n_pinned: int = 1, - ) -> object: + ) -> _PwlInputs: from xarray import DataArray from linopy.constants import BREAKPOINT_DIM, EQUAL, sign_replace_dict @@ -2290,7 +2294,7 @@ def _make_inputs( ) def test_reason_string( self, - kwargs: dict, # type: ignore[type-arg] + kwargs: dict[str, Any], active: object, fragments: tuple[str, ...], ) -> None: @@ -2325,7 +2329,7 @@ def test_eligible_concave_le_returns_ok(self) -> None: class TestEvolvingAPIWarning: @pytest.fixture(autouse=True) - def _reset_dedup(self) -> None: + def _reset_dedup(self) -> Generator[None, None, None]: """ Warnings dedup is module-global so order between tests would otherwise matter. Clear before each test. From c7b1011ed1507d1672721fb76a744f56230cf35b Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 6 May 2026 08:56:04 +0200 Subject: [PATCH 63/65] refac: rename pw repr function doc: adjust figsize in notebook --- examples/piecewise-linear-constraints.ipynb | 2 +- linopy/model.py | 6 +++--- linopy/piecewise.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 737f020b..a7011935 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -340,7 +340,7 @@ "metadata": {}, "outputs": [], "source": [ - "fig, axes = plt.subplots(1, 2, figsize=(7, 3))\n", + "fig, axes = plt.subplots(1, 2, figsize=(8, 3))\n", "plot_curve(\n", " x_pts, y_pts, m.solution[\"power\"].values, m.solution[\"fuel\"].values, ax=axes[0]\n", ")\n", diff --git a/linopy/model.py b/linopy/model.py index c48f8eb6..6858d243 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -503,9 +503,9 @@ def __repr__(self) -> str: """ Return a string representation of the linopy model. """ - from linopy.piecewise import _grouped_names, _repr_summary + from linopy.piecewise import _get_piecewise_groups, _repr_summary as pwl_repr_summary - var_names, con_names = _grouped_names(self) + var_names, con_names = _get_piecewise_groups(self) var_string = self.variables._format_items(exclude=var_names) con_string = self.constraints._format_items(exclude=con_names) model_string = f"Linopy {self.type} model" @@ -514,7 +514,7 @@ def __repr__(self) -> str: f"{model_string}\n{'=' * len(model_string)}\n\n" f"Variables:\n----------\n{var_string}\n" f"Constraints:\n------------\n{con_string}" - f"{_repr_summary(self)}" + f"{pwl_repr_summary(self)}" f"\nStatus:\n-------\n{self.status}" ) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index dd0ebb4e..a7d56c18 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -176,7 +176,7 @@ def __repr__(self) -> str: return r -def _grouped_names(model: Model) -> tuple[set[str], set[str]]: +def _get_piecewise_groups(model: Model) -> tuple[set[str], set[str]]: """ Names of auxiliary variables/constraints that belong to a piecewise formulation. Returned as separate sets because variables and From 3626a996d6d34ebc249c891660097d903a035007 Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 6 May 2026 09:14:48 +0200 Subject: [PATCH 64/65] Fix piecewise validation edge cases --- linopy/piecewise.py | 73 +++++++++++++++++++++++++----- test/test_piecewise_constraints.py | 58 +++++++++++++++++++++++- 2 files changed, 118 insertions(+), 13 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index a7d56c18..c92ad940 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -663,10 +663,17 @@ def _validate_breakpoint_shapes(bp_a: DataArray, bp_b: DataArray) -> bool: def _validate_numeric_breakpoint_coords(bp: DataArray) -> None: - if not pd.api.types.is_numeric_dtype(bp.coords[BREAKPOINT_DIM]): + coord = bp.coords[BREAKPOINT_DIM] + if not pd.api.types.is_numeric_dtype(coord): raise ValueError( f"Breakpoint dimension '{BREAKPOINT_DIM}' must have numeric coordinates " - f"for SOS2 weights, but got {bp.coords[BREAKPOINT_DIM].dtype}" + f"for SOS2 weights, but got {coord.dtype}" + ) + values = np.asarray(coord.values) + if len(values) > 1 and not bool(np.all(np.diff(values) > 0)): + raise ValueError( + f"Breakpoint dimension '{BREAKPOINT_DIM}' coordinates must be " + "strictly increasing for SOS2 weights." ) @@ -721,6 +728,42 @@ def _has_trailing_nan_only(bp: DataArray) -> bool: return not bool((valid & ~cummin_da).any()) +def _paired_valid_points(*points: DataArray) -> DataArray: + invalid = points[0].isnull() + for point in points[1:]: + invalid = invalid | point.isnull() + return points[0].where(~invalid) + + +def _validate_shared_coords(points: Sequence[DataArray]) -> None: + skip = {BREAKPOINT_DIM, SEGMENT_DIM} | set(HELPER_DIMS) + for i, left in enumerate(points): + for right in points[i + 1 :]: + for dim in (set(left.dims) & set(right.dims)) - skip: + left_index = pd.Index(left.coords[dim].values) + right_index = pd.Index(right.coords[dim].values) + if not left_index.equals(right_index): + raise ValueError( + f"Breakpoint coordinates for dimension '{dim}' must match." + ) + + +def _validate_expr_coords( + points: Sequence[DataArray], exprs: Sequence[LinearExpression] +) -> None: + skip = {BREAKPOINT_DIM, SEGMENT_DIM} | set(HELPER_DIMS) + for point in points: + for expr in exprs: + for dim in (set(point.dims) & set(expr.coord_dims)) - skip: + point_index = pd.Index(point.coords[dim].values) + expr_index = pd.Index(expr.coords[dim].values) + if not point_index.equals(expr_index): + raise ValueError( + f"Breakpoint coordinates for dimension '{dim}' must match " + "the expression coordinates." + ) + + def _to_linexpr(expr: LinExprLike) -> LinearExpression: from linopy.expressions import LinearExpression @@ -979,9 +1022,12 @@ def add_piecewise_formulation( _validate_breakpoint_shapes(coerced_bps[0], coerced_bps[i]) raw_exprs = [expr for expr, _, _ in parsed] + lin_exprs = [_to_linexpr(expr) for expr in raw_exprs] bp_list = [ _broadcast_points(bp, *raw_exprs, disjunctive=disjunctive) for bp in coerced_bps ] + _validate_shared_coords(bp_list) + _validate_expr_coords(bp_list, lin_exprs) combined_null = bp_list[0].isnull() for bp in bp_list[1:]: @@ -1003,7 +1049,6 @@ def add_piecewise_formulation( # can't collide with the synthetic coord for an unnamed expr. link_coords.append(f"_pwl_{i}") - lin_exprs = [_to_linexpr(expr) for expr in raw_exprs] active_expr = _to_linexpr(active) if active is not None else None if signed_idx is None: @@ -1158,10 +1203,11 @@ def _lp_eligibility( assert inputs.bounded_bp is not None # narrowed by is_equality check x_pts = inputs.pinned_bps[0] y_pts = inputs.bounded_bp - if not _check_strict_monotonicity(x_pts): - return False, "x breakpoints are not strictly monotonic" - if not _has_trailing_nan_only(x_pts): - return False, "x breakpoints contain non-trailing NaN" + paired_x = _paired_valid_points(x_pts, y_pts) + if not _check_strict_monotonicity(paired_x): + return False, "paired x breakpoints are not strictly monotonic" + if not _has_trailing_nan_only(paired_x): + return False, "paired breakpoints contain non-trailing NaN" convexity = _detect_convexity(x_pts, y_pts) sign = inputs.bounded_sign if sign == LESS_EQUAL and convexity not in ("concave", "linear"): @@ -1290,7 +1336,11 @@ def _resolve_sos2_vs_incremental( is_monotonic = _check_strict_monotonicity(stacked_bp) if method == "auto": - return "incremental" if (is_monotonic and trailing_nan_only) else "sos2" + if not trailing_nan_only: + raise ValueError( + "SOS2 method does not support non-trailing NaN breakpoints." + ) + return "incremental" if is_monotonic else "sos2" if method == "incremental": if not is_monotonic: @@ -1591,8 +1641,9 @@ def _add_lp( mask=piece_mask, ) - # Domain bounds: x ∈ [x_min, x_max] (skipna by default). - x_min = x_points.min(dim=BREAKPOINT_DIM) - x_max = x_points.max(dim=BREAKPOINT_DIM) + # Domain bounds: x ∈ [x_min, x_max] over paired-valid breakpoints. + paired_x_points = x_points.where(bp_valid) + x_min = paired_x_points.min(dim=BREAKPOINT_DIM) + x_max = paired_x_points.max(dim=BREAKPOINT_DIM) model.add_constraints(x_expr >= x_min, name=f"{name}{PWL_DOMAIN_LO_SUFFIX}") model.add_constraints(x_expr <= x_max, name=f"{name}{PWL_DOMAIN_HI_SUFFIX}") diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 9674fcb1..9459eb7e 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -809,7 +809,8 @@ 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_sos2_interior_nan_raises(self) -> None: + @pytest.mark.parametrize("method", ["sos2", "auto"]) + def test_sos2_interior_nan_raises(self, method: Method) -> None: """SOS2 with interior NaN breakpoints raises ValueError.""" m = Model() x = m.add_variables(name="x") @@ -820,7 +821,7 @@ def test_sos2_interior_nan_raises(self) -> None: m.add_piecewise_formulation( (x, x_pts), (y, y_pts), - method="sos2", + method=method, ) @@ -1362,6 +1363,43 @@ def test_non_numeric_breakpoint_coords_raises(self) -> None: method="sos2", ) + def test_unordered_sos2_breakpoint_coords_raise(self) -> None: + """SOS2 breakpoint coords define adjacency and must follow data order.""" + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + x_pts = xr.DataArray( + [0, 1, 2], + dims=[BREAKPOINT_DIM], + coords={BREAKPOINT_DIM: [0, 2, 1]}, + ) + y_pts = xr.DataArray( + [0, 100, 0], + dims=[BREAKPOINT_DIM], + coords={BREAKPOINT_DIM: [0, 2, 1]}, + ) + with pytest.raises(ValueError, match="strictly increasing"): + m.add_piecewise_formulation((x, x_pts), (y, y_pts), method="sos2") + + def test_breakpoint_entity_coords_must_match_expression_coords(self) -> None: + """Entity coords on breakpoints must not silently misalign with variables.""" + m = Model() + entities = pd.Index(["a", "b"], name="entity") + x = m.add_variables(coords=[entities], name="x") + y = m.add_variables(coords=[entities], name="y") + x_pts = xr.DataArray( + [[0, 10], [0, 10]], + dims=["entity", BREAKPOINT_DIM], + coords={"entity": ["a", "b"], BREAKPOINT_DIM: [0, 1]}, + ) + y_pts = xr.DataArray( + [[0, 5], [0, 5]], + dims=["entity", BREAKPOINT_DIM], + coords={"entity": ["b", "c"], BREAKPOINT_DIM: [0, 1]}, + ) + with pytest.raises(ValueError, match="coordinates"): + m.add_piecewise_formulation((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() @@ -1866,6 +1904,22 @@ def test_lp_domain_bound_infeasible_when_x_out_of_range(self) -> None: status, _ = m.solve() assert status != "ok" + @pytest.mark.skipif(not _any_solvers, reason="no solver available") + def test_lp_domain_uses_paired_valid_breakpoints(self) -> None: + """A trailing NaN in y must also shrink the LP x-domain.""" + m = Model() + x = m.add_variables(lower=0, upper=2, name="x") + y = m.add_variables(lower=0, upper=10, name="y") + m.add_piecewise_formulation( + (y, [0, 1, np.nan], "<="), + (x, [0, 1, 2]), + method="lp", + ) + m.add_constraints(x == 2) + m.add_objective(-y) + status, _ = m.solve() + assert status != "ok" + @pytest.mark.skipif(not _any_solvers, reason="no solver available") def test_lp_matches_sos2_on_multi_dim_variables(self) -> None: """ From 0f47a9f9d6d945d99a7441c7e0244fb7c5060803 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 07:15:02 +0000 Subject: [PATCH 65/65] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- linopy/model.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/linopy/model.py b/linopy/model.py index 6858d243..d6e15d83 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -503,7 +503,8 @@ def __repr__(self) -> str: """ Return a string representation of the linopy model. """ - from linopy.piecewise import _get_piecewise_groups, _repr_summary as pwl_repr_summary + from linopy.piecewise import _get_piecewise_groups + from linopy.piecewise import _repr_summary as pwl_repr_summary var_names, con_names = _get_piecewise_groups(self) var_string = self.variables._format_items(exclude=var_names)