diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index e364988c..2acf886d 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -3,19 +3,15 @@ Piecewise Linear Constraints ============================ -Piecewise linear (PWL) constraints approximate nonlinear functions as connected -linear pieces, allowing you to model cost curves, efficiency curves, or -production functions within a linear programming framework. +Piecewise linear (PWL) constraints approximate nonlinear functions as +connected 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. +Throughout this page: a **breakpoint** is a knot where the slope can +change; a **piece** is the linear part between adjacent breakpoints; a +**segment** is a disjoint operating region in the disjunctive +formulation. Per-tuple breakpoint arrays are paired by index — the +``i``-th entries across all tuples together define one knot. .. contents:: :local: @@ -45,19 +41,23 @@ Quick Start .. 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). + # fuel >= f(power) on the same heat-rate curve as above. m.add_piecewise_formulation( - (fuel, [0, 20, 30, 35], "<="), # bounded by the curve - (power, [0, 10, 20, 30]), # pinned to the curve + (fuel, [0, 36, 84, 170], ">="), + (power, [0, 30, 60, 100]), ) -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. +Over-fuelling is physically admissible but wasteful, so minimising +fuel pulls the operating point onto the curve. ``method="auto"`` +picks the cheapest correct formulation: pure LP (chord constraints) +here, since convex + ``">="`` is LP-applicable; SOS2/incremental +otherwise. + +Each ``(expression, breakpoints[, sign])`` tuple pairs a variable with +its breakpoint values. The optional sign (default ``"=="``) is ``"<="`` +or ``">="`` to mark that expression as bounded by the curve. With every +sign ``"=="``, all tuples land on the same point of the piecewise curve +— see *Per-tuple sign* below for the geometry of the inequality cases. API @@ -69,7 +69,7 @@ API .. code-block:: python m.add_piecewise_formulation( - (expr1, breakpoints1), # pinned (sign defaults to "==") + (expr1, breakpoints1), # sign defaults to "==" (equality role) (expr2, breakpoints2, "<="), # or with an explicit sign ..., method="auto", # "auto", "sos2", "incremental", or "lp" @@ -77,148 +77,30 @@ API name=None, # base name for generated variables/constraints ) -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`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -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]) # connected - linopy.breakpoints({"gen1": [0, 50], "gen2": [0, 80]}, dim="gen") # per-entity - linopy.Slopes( - [1.2, 1.4], y0=0 - ) # from slopes (deferred — pairs with a sibling tuple) - linopy.segments([(0, 10), (50, 100)]) # two disjoint regions - linopy.segments({"gen1": [(0, 10)], "gen2": [(0, 80)]}, dim="gen") - - -Per-tuple sign — equality vs inequality ----------------------------------------- - -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. - -.. 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)) - -**Restrictions (current):** - -- At most one tuple may carry a non-equality sign — a single bounded side. -- 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. - -**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: - -.. math:: - - \{ (x, y) \ :\ x_0 \le x \le x_n,\ y \le f(x) \}. - -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 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. 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. - -**When is a one-sided bound wanted?** - -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). - -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 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 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. +Adds constraints — and, depending on the resolved method, auxiliary +variables — for either an all-equality joint (every tuple at the same +point on the curve, the default) or a one-sided bound on a single +tuple. The pure-LP path adds chord and domain constraints only; SOS2, +incremental, and disjunctive also add interpolation weights and/or +binaries (see *Formulation Methods* below). Breakpoint Construction ----------------------- -From lists -~~~~~~~~~~ +Each tuple's breakpoints come from :func:`~linopy.breakpoints` (a +single connected curve) or :func:`~linopy.segments` (disjoint +operating bands). :class:`~linopy.Slopes` can stand in for +:func:`~linopy.breakpoints` when per-piece slopes are the natural +input — it resolves to a breakpoints array. + +``breakpoints()`` — a connected curve +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Values along a single **connected** piecewise curve — the default case +for efficiency curves, heat rates, and cost curves. -The simplest form — pass Python lists directly in the tuple: +The simplest form passes a Python list directly in the tuple: .. code-block:: python @@ -227,9 +109,6 @@ The simplest form — pass Python lists directly in the tuple: (fuel, [0, 36, 84, 170]), ) -With the ``breakpoints()`` factory -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Equivalent, but explicit about the DataArray construction: .. code-block:: python @@ -239,31 +118,8 @@ Equivalent, but explicit about the DataArray construction: (fuel, linopy.breakpoints([0, 36, 84, 170])), ) -From slopes -~~~~~~~~~~~ - -When you know marginal costs (slopes) rather than absolute values, wrap -them in :class:`linopy.Slopes`. The x grid is borrowed from the sibling -tuple — no need to repeat it: - -.. code-block:: python - - m.add_piecewise_formulation( - (power, [0, 50, 100, 150]), - (cost, linopy.Slopes([1.1, 1.5, 1.9], y0=0)), - ) - # cost breakpoints: [0, 55, 130, 225] - -For standalone resolution outside of ``add_piecewise_formulation``, call -:meth:`linopy.Slopes.to_breakpoints` with an explicit x grid:: - - bp = linopy.Slopes([1.1, 1.5, 1.9], y0=0).to_breakpoints([0, 50, 100, 150]) - -Per-entity breakpoints -~~~~~~~~~~~~~~~~~~~~~~ - -Different generators can have different curves. Pass a dict to -``breakpoints()`` with entity names as keys: +**Per-entity curves.** Different generators can have different +curves. Pass a dict to ``breakpoints()`` with entity names as keys: .. code-block:: python @@ -282,28 +138,56 @@ 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``). + +**Specifying by slopes.** :class:`linopy.Slopes` resolves to a +breakpoint array from per-piece slopes plus an initial ``y0``, +instead of from absolute y-values — useful when slopes are the +natural input (e.g. marginal costs). The x grid is borrowed from +the sibling tuple, so the y breakpoints don't have to be computed +by hand: + +.. code-block:: python + + m.add_piecewise_formulation( + (power, [0, 50, 100, 150]), + (cost, linopy.Slopes([1.1, 1.5, 1.9], y0=0)), + ) + # cost breakpoints resolve to: [0, 55, 130, 225] + +For standalone resolution outside ``add_piecewise_formulation``, call +:meth:`linopy.Slopes.to_breakpoints` with an explicit x grid:: -Disjunctive segments -~~~~~~~~~~~~~~~~~~~~ + bp = linopy.Slopes([1.1, 1.5, 1.9], y0=0).to_breakpoints([0, 50, 100, 150]) + +``segments()`` — disjoint operating bands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -For disconnected operating regions (e.g. forbidden zones), use ``segments()``: +For equipment with disconnected operating bands. Each segment is one +band's ``(range, curve)``; a binary picks exactly one per operating +point, with continuous interpolation within the chosen band. .. code-block:: python + # Stepped pump with two speed bands. m.add_piecewise_formulation( - (power, linopy.segments([(0, 0), (50, 80)])), - (cost, linopy.segments([(0, 0), (125, 200)])), + (flow, linopy.segments([(5, 25), (40, 100)])), + (power, linopy.segments([(1, 7), (15, 50)])), ) -The disjunctive formulation is selected automatically when breakpoints have a -segment dimension. A bounded tuple (``"<="`` / ``">="``) also works here. +Bounded tuples (``"<="`` / ``">="``) are supported on disjunctive +curves too. + +For a single on/off gate on one continuous curve, prefer ``active=...`` +(see :ref:`piecewise-active`) — using a degenerate ``(0, 0)`` segment +to encode "off" mixes the disjunctive concept with on/off logic. N-variable linking ~~~~~~~~~~~~~~~~~~ -Link any number of variables through shared breakpoints (joint equality): +Independent of the building block used, any number of variables can be +linked through shared breakpoints (joint equality): .. code-block:: python @@ -314,11 +198,117 @@ Link any number of variables through shared breakpoints (joint 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. +``λ``-weighted combination of breakpoints across all three. Sign +restrictions apply (see *Per-tuple sign* below) — for ``N ≥ 3`` tuples +all signs must be ``"=="``. + +Per-tuple sign — equality vs inequality +---------------------------------------- + +Roles and restrictions +~~~~~~~~~~~~~~~~~~~~~~ + +Each tuple's optional third element is a sign: + +- ``"=="`` (default) — **equality role**: the tuple enters as an + equality. +- ``"<="`` / ``">="`` — **bounded**: the expression undershoots / + overshoots the curve. + +.. note:: + + **Current restrictions.** + + - At most one tuple may carry a non-equality sign — a single bounded side. + - 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. + +Geometry +~~~~~~~~ + +What the formulation actually constrains depends on the tuple count and +signs: + +- **All-equality (default).** Shared interpolation weights put the + joint :math:`(e_1, \ldots, e_N)` exactly on the curve. +- **One bounded + one equality (2 tuples).** The joint :math:`(x, y)` + lies in the **hypograph** (``"<="`` on a concave :math:`f`) or + **epigraph** (``">="`` on a convex :math:`f`): + + .. math:: + + \{ (x, y) \ :\ x_{\min} \le x \le x_{\max},\ y \le f(x) \} + \qquad \text{(hypograph)} + + The equality axis is just confined to its breakpoint domain + :math:`[x_{\min}, x_{\max}]` — a single coordinate can't locate a + curve point. Same projection in LP (enforced directly) and + SOS2/incremental (enforced via the weight link). +- **Mismatched sign + curvature** (convex + ``"<="``, or concave + + ``">="``) describes a *non-convex* region — ``method="auto"`` falls + back to SOS2/incremental and ``method="lp"`` raises. + +.. code-block:: python + + # All-equality: joint (x, y) on the curve. + m.add_piecewise_formulation((y, y_pts), (x, x_pts)) + + # Bounded: joint (x, y) in the hypograph — y ≤ f(x), x ∈ [x_min, x_max]. + m.add_piecewise_formulation((y, y_pts, "<="), (x, x_pts)) + + # Bounded: joint (x, y) in the epigraph — y ≥ f(x), x ∈ [x_min, x_max]. + m.add_piecewise_formulation((y, y_pts, ">="), (x, x_pts)) + + # 3-variable all-equality (CHP): joint (power, fuel, heat) on the curve. + m.add_piecewise_formulation((power, p_pts), (fuel, f_pts), (heat, h_pts)) + +Choice of bounded tuple and sign +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Pick the sign matching the physically admissible direction for that +expression: + +- ``"<="`` for a quantity with a controllable *dissipation path* — heat + rejection via cooling tower (*thermal curtailment*), electrical + curtailment, emissions after post-treatment — so undershooting the + curve is realisable. +- ``">="`` for an *input* whose over-supply is admissible but wasteful — + fuel, raw materials — so overshooting the curve is realisable + (objective pressure then pulls the operating point onto the curve). + +The wrong direction (``"<="`` on fuel, ``">="`` on a non-curtailable +output) yields a valid but **loose** formulation that admits operating +points the plant cannot physically realise; an objective rewarding the +wrong direction may then find a non-physical optimum — safe only when +no such objective pressure exists. + +When is a one-sided bound wanted? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +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). + +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 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. Formulation Methods ------------------- @@ -330,11 +320,13 @@ formulation based on ``sign``, curvature and breakpoint layout: no auxiliary variables) - **All breakpoints monotonic** → ``incremental`` - **Otherwise** → ``sos2`` -- **Disjunctive (segments)** → always ``sos2`` with binary segment selection +- **Disjunctive (segments)** → SOS2 applied per segment with binary + segment selection (the disjunctive formulation in the table below). -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. +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: @@ -376,7 +368,7 @@ At-a-glance comparison: - **None** - Continuous + binary - Continuous + SOS2 - - Binary + SOS2 + - Continuous + binary + SOS2 * - ``active=`` supported - No - Yes @@ -385,33 +377,43 @@ At-a-glance comparison: * - Solver requirement - **Any LP solver** - MIP-capable - - SOS2-capable - - SOS2 + MIP + - SOS2-capable (or MIP via :ref:`Big-M reformulation `) + - SOS2 + MIP (or MIP via :ref:`Big-M reformulation `) + +.. note:: + + Disjunctive formulations report ``method="sos2"`` (the underlying + per-segment encoding uses SOS2), but the table treats them as a + separate column because the per-segment binaries change the + auxiliary-variable structure and solver requirements. -LP (chord-line) Formulation +LP (chord-line) formulation ~~~~~~~~~~~~~~~~~~~~~~~~~~~ -For **2-variable inequality** on a **convex** or **concave** curve. Adds one -chord inequality per piece plus a domain bound — no auxiliary variables and -no MIP relaxation: +For **2-variable inequality** on a **convex** or **concave** curve. +Adds one 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{pieces } k - &x_0 \le x \le x_n + &x_{\min} \le x \le x_{\max} 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="<="``, +:math:`c_k = y_k - m_k\, x_k`. The domain bound uses :math:`x_{\min}` +and :math:`x_{\max}` rather than the first/last breakpoint so that +descending x grids work too — strictly-monotonic breakpoints are +accepted in either order. 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. +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 @@ -421,18 +423,26 @@ this and falls back; ``method="lp"`` raises. # 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. +**Not supported with** ``method="lp"``: all-equality, more than 2 +tuples, and ``active``. ``method="auto"`` falls back to +SOS2/incremental in all three cases. + +Chord expressions as a building block +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The underlying chord expressions are also exposed as a standalone +helper, :func:`~linopy.tangent_lines`, which returns the per-piece +chord as a :class:`~linopy.expressions.LinearExpression` with no +variables created. Use it directly when you want to compose the chord +bound with other constraints by hand, without the domain bound that +``method="lp"`` adds automatically: + +.. code-block:: python -The underlying chord expressions are also exposed as a standalone helper, -``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 -automatically. + chord = linopy.tangent_lines(x, x_pts, y_pts) + m.add_constraints(y <= chord + slack) -Incremental (Delta) Formulation +Incremental (Delta) formulation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The default MIP encoding when ``method="auto"`` is in play and breakpoints @@ -449,8 +459,7 @@ binary indicators :math:`z_i`: &e_j = B_{j,0} + \sum_{i=1}^{n} \delta_i \, (B_{j,i} - B_{j,i-1}) 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). +requested sign while the equality-signed tuples keep the equality above. .. code-block:: python @@ -458,7 +467,7 @@ the *Per-tuple sign* section's *Formulation* block). **Limitation:** breakpoint sequences must be strictly monotonic. -SOS2 (Convex Combination) +SOS2 (Convex combination) ~~~~~~~~~~~~~~~~~~~~~~~~~~ Fallback when breakpoints aren't strictly monotonic (the only case @@ -494,7 +503,7 @@ above. m.add_piecewise_formulation((power, xp), (fuel, yp), method="sos2") -Disjunctive (Disaggregated Convex Combination) +Disjunctive (Disaggregated convex combination) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ For **disconnected segments** (gaps between operating regions). Binary @@ -522,6 +531,8 @@ disconnected operating regions" that ``method="lp"`` cannot handle. Advanced Features ----------------- +.. _piecewise-active: + Active parameter (unit commitment) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -541,15 +552,18 @@ 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"``. +Not supported with ``method="lp"`` (gating needs a binary). Use +``method="auto"``, or *Chord expressions as a building block* for +manual gating. -.. note:: +.. warning:: - 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 per-tuple sign section above for details. + 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. Auto-broadcasting ~~~~~~~~~~~~~~~~~ diff --git a/examples/piecewise-inequality-bounds.ipynb b/examples/piecewise-inequality-bounds.ipynb index a79c612d..d1ca4e79 100644 --- a/examples/piecewise-inequality-bounds.ipynb +++ b/examples/piecewise-inequality-bounds.ipynb @@ -4,28 +4,30 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Piecewise inequalities \u2014 per-tuple sign\n", + "# Creating Piecewise Inequality Bounds\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", + "When you only need a one-sided bound from a piecewise curve — `y ≤ f(x)` for a concave upper envelope, `y ≥ f(x)` for a convex lower envelope — `add_piecewise_formulation` accepts an optional sign as the third tuple element:\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", + " (fuel, fuel_pts, \">=\"), # fuel ≥ f(power) — over-fuelling admissible\n", + " (power, power_pts), # equality role\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", + "The pay-off is a pure-LP encoding when the curve's curvature matches the sign — no SOS2, no binaries. This notebook covers the geometry of the feasible region, the curvature × sign combinations that unlock the LP path, and what happens when they don't match.\n", "\n", - "## Key points\n", + "For the formulation math see the [reference page](piecewise-linear-constraints.rst); for the all-equality variant and other features see [Creating Piecewise Linear Constraints](piecewise-linear-constraints-tutorial.nblink).\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", + "## Tuple roles\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." + "| Tuple form | Role | What it constrains |\n", + "|---|---|---|\n", + "| `(expr, breaks)` | `==` (equality) | With 2+ equality tuples sharing weights, the joint point lies on the curve. With 1 equality + 1 bounded, the equality tuple's marginal feasible set is just its breakpoint domain `[x_min, x_max]` — one coordinate alone can't locate a curve point. |\n", + "| `(expr, breaks, \"<=\")` | bounded above | `expr ≤ f(other tuples)`. |\n", + "| `(expr, breaks, \">=\")` | bounded below | `expr ≥ 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≥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." ] }, { @@ -39,16 +41,27 @@ }, "outputs": [], "source": [ + "import warnings\n", + "\n", "import matplotlib.pyplot as plt\n", "import numpy as np\n", "\n", - "import linopy" + "import linopy\n", + "\n", + "# Silence the evolving-API warning for cleaner tutorial output.\n", + "warnings.filterwarnings(\"ignore\", category=linopy.EvolvingAPIWarning)" ] }, { "cell_type": "markdown", "metadata": {}, - "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)." + "source": [ + "## Setup — a convex heat-rate curve\n", + "\n", + "A convex, monotonically increasing curve maps power output to the fuel required (the classic heat-rate curve). Bounding `fuel` by this curve with `>=` says the unit must consume *at least* the design fuel for a given power output — over-fuelling is physically admissible but wasteful, so an objective that minimises fuel pulls the operating point onto the curve. Convex + `>=` is exactly the combination that lets the LP method apply.\n", + "\n", + "The breakpoint arrays:" + ] }, { "cell_type": "code", @@ -61,14 +74,8 @@ }, "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()" + "power_pts = np.array([0.0, 30.0, 60.0, 100.0])\n", + "fuel_pts = np.array([0.0, 36.0, 84.0, 170.0]) # slopes 1.2, 1.6, 2.15 (convex)" ] }, { @@ -77,15 +84,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", + "With `fuel` bounded `>=` and our convex curve, the three methods give the **same** feasible region for `power ∈ [0, 100]`:\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", + "- **`method=\"lp\"`** — tangent lines + domain bounds. No auxiliary variables.\n", + "- **`method=\"sos2\"`** — lambdas + SOS2 + a split link: equality for the equality-signed tuple, signed for the bounded one. Solver picks the piece.\n", + "- **`method=\"incremental\"`** — 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", + "`method=\"auto\"` dispatches to `\"lp\"` whenever applicable — it's always preferable because it's pure LP.\n", "\n", - "Let's verify they produce the same solution at `power=15`." + "Let's verify they produce the same solution at `power=60`, where `f(60)=84`." ] }, { @@ -98,17 +105,53 @@ } }, "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, \"<=\"), # 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_}\")" + "source": [ + "def solve(method, power_val):\n", + " m = linopy.Model()\n", + " power = m.add_variables(lower=0, upper=100, name=\"power\")\n", + " fuel = m.add_variables(lower=0, upper=200, name=\"fuel\")\n", + " m.add_piecewise_formulation(\n", + " (fuel, fuel_pts, \">=\"), # fuel ≥ f(power) — over-fuelling admissible\n", + " (power, power_pts), # equality role (domain-bounded to [0, 100])\n", + " method=method,\n", + " )\n", + " m.add_constraints(power == power_val)\n", + " m.add_objective(fuel) # minimise fuel against the lower bound\n", + " m.solve(solver_name=\"highs\", reformulate_sos=\"auto\", output_flag=False)\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, 60)\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) \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." + "source": [ + "All three give `fuel=84` at `power=60` (which is `f(60)` exactly) — the math is equivalent. The LP method is strictly cheaper: no auxiliary variables, just three chord constraints and two domain bounds.\n", + "\n", + "The SOS2 and incremental methods create lambdas (or deltas + binaries) and split the link into an equality constraint for the equality-signed tuple plus a signed link for the bounded tuple — 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)` \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" + "source": [ + "## Visualising the feasible region\n", + "\n", + "The feasible region for `(power, fuel)` with `fuel` bounded `>=` is the **epigraph** of `f` restricted to the power domain:\n", + "\n", + "$$\\{ (\\mathrm{power}, \\mathrm{fuel}) : 0 \\le \\mathrm{power} \\le 100,\\ \\mathrm{fuel} \\ge f(\\mathrm{power}) \\}$$\n", + "\n", + "Below we colour feasible points green, infeasible ones red:\n", + "\n", + "- `(60, 100)` — above the curve, `100 ≥ f(60)=84` ✓\n", + "- `(60, 84)` — on the curve ✓\n", + "- `(60, 70)` — below `f(60)`, infeasible ✗\n", + "- `(120, 100)` — power beyond domain, infeasible ✗" + ] }, { "cell_type": "code", @@ -121,30 +164,30 @@ }, "outputs": [], "source": [ - "def in_hypograph(px, py):\n", - " if px < x_pts[0] or px > x_pts[-1]:\n", + "def in_epigraph(px, fy):\n", + " if px < power_pts[0] or px > power_pts[-1]:\n", " return False\n", - " return py <= np.interp(px, x_pts, y_pts)\n", + " return fy >= np.interp(px, power_pts, fuel_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", + "xx, yy = np.meshgrid(np.linspace(-10, 130, 200), np.linspace(-10, 200, 200))\n", + "region = np.vectorize(in_epigraph)(xx, yy)\n", "\n", - "test_points = [(15, 15), (15, 25), (15, 29), (35, 20)]\n", + "test_points = [(60, 100), (60, 84), (60, 70), (120, 100)]\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.plot(power_pts, fuel_pts, \"o-\", color=\"C0\", lw=2, label=\"f(power)\")\n", + "for px, fy in test_points:\n", + " feas = in_epigraph(px, fy)\n", " ax.scatter(\n", - " [px], [py], color=\"green\" if feas else \"red\", zorder=5, s=80, edgecolors=\"black\"\n", + " [px], [fy], 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.annotate(f\"({px}, {fy})\", (px, fy), textcoords=\"offset points\", xytext=(8, 5))\n", "ax.set(\n", " xlabel=\"power\",\n", " ylabel=\"fuel\",\n", - " title=\"sign='<=' feasible region \u2014 hypograph of f(x) on [x_0, x_n]\",\n", + " title=\"sign='>=' feasible region — epigraph of f(power) on [0, 100]\",\n", ")\n", "ax.grid(alpha=0.3)\n", "ax.legend()\n", @@ -157,16 +200,16 @@ "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", + "`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 \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", + "| **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 \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", + "In 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 piece extrapolated over another piece'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 piece selection.\n", "\n", @@ -185,12 +228,51 @@ } }, "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 \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}\")" + "source": [ + "# 1. Non-convex curve: auto falls back (LP relaxation would be loose)\n", + "power_nc = [0, 30, 60, 100]\n", + "fuel_nc = [0, 50, 30, 80] # slopes change sign → mixed convexity\n", + "\n", + "m1 = linopy.Model()\n", + "power1 = m1.add_variables(lower=0, upper=100, name=\"power\")\n", + "fuel1 = m1.add_variables(lower=0, upper=200, name=\"fuel\")\n", + "f1 = m1.add_piecewise_formulation((fuel1, fuel_nc, \">=\"), (power1, power_nc))\n", + "print(f\"non-convex + '>=' → {f1.method}\")\n", + "\n", + "# 2. Convex curve + sign='<=': LP would be loose → auto falls back to MIP\n", + "m2 = linopy.Model()\n", + "power2 = m2.add_variables(lower=0, upper=100, name=\"power\")\n", + "fuel2 = m2.add_variables(lower=0, upper=200, name=\"fuel\")\n", + "f2 = m2.add_piecewise_formulation(\n", + " (fuel2, list(fuel_pts), \"<=\"), (power2, list(power_pts))\n", + ")\n", + "print(f\"convex + '<=' → {f2.method}\")\n", + "\n", + "# 3. Explicit method=\"lp\" with mismatched curvature raises\n", + "try:\n", + " m3 = linopy.Model()\n", + " power3 = m3.add_variables(lower=0, upper=100, name=\"power\")\n", + " fuel3 = m3.add_variables(lower=0, upper=200, name=\"fuel\")\n", + " m3.add_piecewise_formulation(\n", + " (fuel3, list(fuel_pts), \"<=\"), (power3, list(power_pts)), method=\"lp\"\n", + " )\n", + "except ValueError as e:\n", + " print(f\"lp(convex, '<=') → 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\u22653 inequality use cases \u2014 please open an issue at https://github.com/PyPSA/linopy/issues so we can scope them." + "source": [ + "## Summary\n", + "\n", + "- **One bounded tuple + a 2-variable formulation** gives a hypograph (`<=`) or epigraph (`>=`) feasible region.\n", + "- **Curvature × sign matching** — concave + `<=` or convex + `>=` — lets `method=\"auto\"` skip MIP entirely. Mismatched combinations fall back to SOS2/incremental with a signed link.\n", + "- **`method=\"lp\"` is strict** — it raises on a mismatched curvature rather than silently encoding the wrong region.\n", + "- At most one tuple may carry a non-`==` sign, and 3+ tuples must all be `==`. Multi-bounded / N≥3 inequalities — open an issue at https://github.com/PyPSA/linopy/issues.\n", + "\n", + "**See also**: [reference page](piecewise-linear-constraints.rst) for the formulation math, [Creating Piecewise Linear Constraints](piecewise-linear-constraints-tutorial.nblink) for all-equality, unit commitment, CHP, fleets, slopes." + ] } ], "metadata": { diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 392ca8f1..8b4f56ca 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -4,37 +4,51 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Piecewise Linear Constraints Tutorial\n", + "# Creating Piecewise Linear Constraints\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", + "`add_piecewise_formulation` links variables through a shared piecewise-linear curve. Pair each variable with its breakpoint values; the solver puts every variable on the *same* point of the curve at every feasible solution.\n", "\n", "```python\n", "m.add_piecewise_formulation(\n", " (power, [0, 30, 60, 100]),\n", " (fuel, [0, 36, 84, 170]),\n", ")\n", - "```" + "```\n", + "\n", + "This tutorial walks through the main features of `add_piecewise_formulation`. For the formulation math see the [reference page](piecewise-linear-constraints.rst); for the inequality variant in depth see [Creating Piecewise Inequality Bounds](piecewise-inequality-bounds-tutorial.nblink).\n", + "\n", + "**Roadmap**\n", + "\n", + "1. Getting started — the basic 2-variable equality.\n", + "2. Picking a method — `\"auto\"`, `\"sos2\"`, `\"incremental\"`, `\"lp\"`.\n", + "3. Disjunctive segments — disconnected operating regions with `segments()`.\n", + "4. Inequality bounds — `<=` / `>=` per-tuple sign.\n", + "5. Unit commitment — gating with `active=...`.\n", + "6. N-variable linking — CHP plants and beyond.\n", + "7. Per-entity breakpoints — fleets with different curves.\n", + "8. Specifying with slopes — `linopy.Slopes`." ] }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-22T23:31:58.302751Z", - "start_time": "2026-04-22T23:31:58.299283Z" + "end_time": "2026-05-11T18:01:54.620516Z", + "start_time": "2026-05-11T18:01:54.613427Z" } }, - "outputs": [], "source": [ + "import warnings\n", + "\n", "import matplotlib.pyplot as plt\n", "import pandas as pd\n", "import xarray as xr\n", "\n", "import linopy\n", "\n", + "# Silence the evolving-API warning for cleaner tutorial output.\n", + "warnings.filterwarnings(\"ignore\", category=linopy.EvolvingAPIWarning)\n", + "\n", "time = pd.Index([1, 2, 3], name=\"time\")\n", "\n", "\n", @@ -48,7 +62,9 @@ " ax.set(xlabel=xlabel, ylabel=ylabel)\n", " ax.legend()\n", " return ax" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -61,14 +77,12 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-22T23:31:58.464773Z", - "start_time": "2026-04-22T23:31:58.310016Z" + "end_time": "2026-05-11T18:01:54.730Z", + "start_time": "2026-05-11T18:01:54.625751Z" } }, - "outputs": [], "source": [ "demand = xr.DataArray([50, 80, 30], coords=[time])\n", "\n", @@ -81,25 +95,27 @@ "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", + "m.solve(solver_name=\"highs\", reformulate_sos=\"auto\", output_flag=False)\n", "\n", "print(pwf) # inspect the auto-resolved method\n", "m.solution[[\"power\", \"fuel\"]].to_pandas()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-22T23:31:58.532078Z", - "start_time": "2026-04-22T23:31:58.473509Z" + "end_time": "2026-05-11T18:01:54.780034Z", + "start_time": "2026-05-11T18:01:54.735021Z" } }, - "outputs": [], "source": [ "plot_curve(x_pts, y_pts, m.solution[\"power\"].values, m.solution[\"fuel\"].values);" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -107,27 +123,21 @@ "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 are `\"sos2\"`, `\"incremental\"`, and `\"lp\"`; the choice is about **cost** (auxiliary variables, solver capability), not correctness — on cases where they all apply they give the same optimum.\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", + "For now: a quick sanity check that all applicable methods yield the same fuel dispatch on the convex curve from §1.\n", "\n", - "Below: all applicable methods yield the same fuel dispatch on this convex curve." + "A full comparison — when each method dispatches, what sign/curvature/breakpoint patterns each requires — lives in §3 (disjunctive), §4 (inequalities), and the [reference page's \"Formulation Methods\" section](piecewise-linear-constraints.rst)." ] }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-22T23:31:58.952185Z", - "start_time": "2026-04-22T23:31:58.537015Z" + "end_time": "2026-05-11T18:01:55.102903Z", + "start_time": "2026-05-11T18:01:54.783092Z" } }, - "outputs": [], "source": [ "def solve_method(method):\n", " m = linopy.Model()\n", @@ -136,54 +146,70 @@ " 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", + " m.solve(solver_name=\"highs\", reformulate_sos=\"auto\", output_flag=False)\n", " return m.solution[\"fuel\"].to_pandas()\n", "\n", "\n", "pd.DataFrame({m: solve_method(m) for m in [\"auto\", \"sos2\", \"incremental\"]})" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", "metadata": {}, "source": [ - "## 3. Disjunctive segments — gaps in the operating range\n", + "## 3. Disjunctive segments — discrete operating bands\n", + "\n", + "Some equipment has **disjoint operating ranges** rather than a continuous one. A stepped pump has two speed bands with a forbidden zone between them — the pump physically can't operate in that gap. `segments()` models this directly: one segment per band, a binary picks exactly one per operating point.\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." + "Below: two pumps in parallel, each with a low band (5–25 m³/h) and a high band (40–100 m³/h). Demands that land in the single-pump gap or above its maximum force the optimiser to combine bands across the two pumps.\n", + "\n", + "(For an on/off gate on a single continuous curve, use `active=...` instead; see §5.)" ] }, { "cell_type": "code", - "execution_count": null, "metadata": { + "scrolled": true, "ExecuteTime": { - "end_time": "2026-04-22T23:31:59.092539Z", - "start_time": "2026-04-22T23:31:58.956054Z" - }, - "scrolled": true + "end_time": "2026-05-11T18:01:55.257839Z", + "start_time": "2026-05-11T18:01:55.114836Z" + } }, - "outputs": [], "source": [ + "pumps = pd.Index([\"p1\", \"p2\"], name=\"pump\")\n", + "\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", + "flow = m.add_variables(name=\"flow\", lower=0, upper=100, coords=[pumps, time])\n", + "power = m.add_variables(name=\"power\", lower=0, coords=[pumps, time])\n", "\n", + "# Each pump has two operating bands; the gap between them is a forbidden zone.\n", "m.add_piecewise_formulation(\n", - " (power, linopy.segments([(0, 0), (50, 80)])), # two disjoint segments\n", - " (cost, linopy.segments([(0, 0), (125, 200)])),\n", + " (flow, linopy.segments([(5, 25), (40, 100)])),\n", + " (power, linopy.segments([(1, 7), (15, 50)])),\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()" - ] + "m.add_constraints(flow.sum(\"pump\") == xr.DataArray([30, 75, 150], coords=[time]))\n", + "m.add_objective(power.sum())\n", + "m.solve(solver_name=\"highs\", reformulate_sos=\"auto\", output_flag=False)\n", + "\n", + "# Flat columns: flow_p1, flow_p2, power_p1, power_p2 per timestep.\n", + "sol = m.solution[[\"flow\", \"power\"]].to_dataframe().unstack(\"pump\")\n", + "sol.columns = [f\"{var}_{p}\" for var, p in sol.columns]\n", + "sol" + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", "metadata": {}, "source": [ - "At *t=1* the 15 MW demand falls in the forbidden zone; the unit sits at 0 and backup fills the gap." + "Every timestep is single-pump-infeasible:\n", + "\n", + "- *t=1*, demand=30: in the single-pump gap (25, 40). Both pumps run in low band, splitting the load.\n", + "- *t=2*, demand=75: too much for low+low (max 50), too little for high+high (min 80). The low pump tops out at 25 m³/h; the high pump covers the remaining 50.\n", + "- *t=3*, demand=150: above a single pump's maximum (100). Both pumps run in high band." ] }, { @@ -192,54 +218,59 @@ "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", + "Append a third tuple element (`\"<=\"` or `\">=\"`) to mark a single expression as **bounded** by the curve instead of entering as an equality. 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." + "Below: the same heat-rate curve as §1, now read as `fuel ≥ f(power)` — over-fuelling is admissible but wasteful (the curve is the design minimum), so an objective that minimises fuel pulls the operating point onto the curve. See [Creating Piecewise Inequality Bounds](piecewise-inequality-bounds-tutorial.nblink) for mismatched curvature, auto-dispatch fallbacks, and more geometry." ] }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-22T23:31:59.210868Z", - "start_time": "2026-04-22T23:31:59.098774Z" + "end_time": "2026-05-11T18:01:55.331357Z", + "start_time": "2026-05-11T18:01:55.269Z" } }, - "outputs": [], "source": [ "m = linopy.Model()\n", - "power = m.add_variables(name=\"power\", lower=0, upper=120, coords=[time])\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", - "# concave curve: diminishing marginal fuel per MW\n", - "x_pts = [0, 50, 90, 120]\n", - "y_pts = [0, 40, 80, 120]\n", + "# Same convex heat-rate curve as §1, now bounded with \">=\"\n", "pwf = m.add_piecewise_formulation(\n", - " (fuel, x_pts, \"<=\"), # bounded above by the curve\n", - " (power, y_pts), # pinned to the curve\n", + " (fuel, [0, 36, 84, 170], \">=\"), # fuel ≥ f(power) — over-fuelling allowed\n", + " (power, [0, 30, 60, 100]), # equality role\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", + "m.add_constraints(power == demand)\n", + "m.add_objective(fuel.sum()) # minimise fuel against the lower bound\n", + "m.solve(solver_name=\"highs\", reformulate_sos=\"auto\", output_flag=False)\n", "\n", "print(f\"resolved method={pwf.method}, curvature={pwf.convexity}\")\n", "m.solution[[\"power\", \"fuel\"]].to_pandas()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-05-11T18:01:55.381548Z", + "start_time": "2026-05-11T18:01:55.337053Z" + } + }, "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);" - ] + "plot_curve(\n", + " [0, 30, 60, 100],\n", + " [0, 36, 84, 170],\n", + " m.solution[\"power\"].values,\n", + " m.solution[\"fuel\"].values,\n", + ");" + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -255,14 +286,12 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-22T23:31:59.422636Z", - "start_time": "2026-04-22T23:31:59.232150Z" + "end_time": "2026-05-11T18:01:55.558321Z", + "start_time": "2026-05-11T18:01:55.386257Z" } }, - "outputs": [], "source": [ "m = linopy.Model()\n", "p_min, p_max = 30, 100\n", @@ -282,18 +311,25 @@ "# 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.solve(solver_name=\"highs\", reformulate_sos=\"auto\", output_flag=False)\n", "m.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-05-11T18:01:55.609973Z", + "start_time": "2026-05-11T18:01:55.564366Z" + } + }, "source": [ "plot_curve(x_pts, y_pts, m.solution[\"power\"].values, m.solution[\"fuel\"].values);" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -306,14 +342,12 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-22T23:31:59.598540Z", - "start_time": "2026-04-22T23:31:59.433551Z" + "end_time": "2026-05-11T18:01:55.731111Z", + "start_time": "2026-05-11T18:01:55.619583Z" } }, - "outputs": [], "source": [ "m = linopy.Model()\n", "power = m.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", @@ -330,15 +364,20 @@ ")\n", "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.solve(solver_name=\"highs\", reformulate_sos=\"auto\", output_flag=False)\n", "m.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas().round(2)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-05-11T18:01:55.811271Z", + "start_time": "2026-05-11T18:01:55.738346Z" + } + }, "source": [ "fig, axes = plt.subplots(1, 2, figsize=(8, 3))\n", "plot_curve(\n", @@ -352,7 +391,9 @@ " ylabel=\"heat\",\n", " ax=axes[1],\n", ");" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -365,14 +406,12 @@ }, { "cell_type": "code", - "execution_count": null, "metadata": { "ExecuteTime": { - "end_time": "2026-04-22T23:31:59.801734Z", - "start_time": "2026-04-22T23:31:59.606692Z" + "end_time": "2026-05-11T18:01:55.957075Z", + "start_time": "2026-05-11T18:01:55.820261Z" } }, - "outputs": [], "source": [ "gens = pd.Index([\"gas\", \"coal\"], name=\"gen\")\n", "x_gen = linopy.breakpoints(\n", @@ -388,9 +427,11 @@ "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.solve(solver_name=\"highs\", reformulate_sos=\"auto\", output_flag=False)\n", "m.solution[[\"power\", \"fuel\"]].to_dataframe()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "markdown", @@ -403,9 +444,12 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "metadata": { + "ExecuteTime": { + "end_time": "2026-05-11T18:01:56.057514Z", + "start_time": "2026-05-11T18:01:55.964137Z" + } + }, "source": [ "m = linopy.Model()\n", "power = m.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", @@ -417,9 +461,30 @@ ")\n", "m.add_constraints(power == demand, name=\"demand\")\n", "m.add_objective(fuel.sum())\n", - "m.solve(reformulate_sos=\"auto\")\n", + "m.solve(solver_name=\"highs\", reformulate_sos=\"auto\", output_flag=False)\n", "\n", "m.solution[[\"power\", \"fuel\"]].to_pandas()" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## When to use what\n", + "\n", + "| Pattern | API |\n", + "|---|---|\n", + "| `y` is a function of `x` | `(x, x_pts), (y, y_pts)` — all-equality |\n", + "| `y` bounded by `f(x)` on a convex/concave curve | `(y, y_pts, \"<=\"` or `\">=\"), (x, x_pts)` — LP if curvature matches |\n", + "| Disconnected operating regions | `linopy.segments(...)` per tuple |\n", + "| Unit on/off coupling | `active=binary_var` |\n", + "| Multiple synchronized outputs (e.g. CHP) | 3+ tuples, all `\"==\"` |\n", + "| Different curves per entity | `linopy.breakpoints({...}, dim=...)` |\n", + "| Slopes more natural than absolute y-values | `linopy.Slopes(...)` |\n", + "\n", + "For the formulation math, see the [reference page](piecewise-linear-constraints.rst). For inequality bounds in depth, see [Creating Piecewise Inequality Bounds](piecewise-inequality-bounds-tutorial.nblink)." ] } ],