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 c5de3563..d3078fff 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 @@ -947,11 +1037,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",