From 93419c2ad7ab98114903dc3f1093c7aa33bdc7dd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:40:37 +0200 Subject: [PATCH 1/4] refactor(piecewise): per-tuple sign + categorized internal flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public API - Drop the formulation-level `sign=` keyword on `add_piecewise_formulation`. Pass the sign per-tuple as an optional 3rd element instead: `(y, y_pts, "<=")` instead of `sign="<="`. - Tuples without a sign default to "=="; the bounded tuple need not be first. - Validate: at most one tuple may carry a non-equality sign; with 3 or more tuples all signs must be "==" (the multi-input bounded case is reserved for a future bivariate / triangulated piecewise API). - Old `sign=` callers get a clear `TypeError` pointing to the new shape. Internal flow - Introduce `_PwlInputs` to carry the categorized inputs (`bounded_*` vs `pinned_*`) through the dispatch chain. `_build_links`, `_try_lp`, `_lp_eligibility`, `_add_continuous`, `_add_disjunctive` all consume it directly — no more positional "first tuple is special" convention. - User's tuple order is preserved end-to-end. Tests - Migrate ~30 callers to per-tuple sign. - Drop tests of the now-rejected N>=3 + non-equality case (`TestNVariableInequality`, the two CHP `TestHandComputedAnchors` cases, `test_nvar_inequality_bounds_first_tuple`). - Add tests for: removed-`sign=`-keyword migration error, multiple bounded tuples rejected, N>=3 + non-equality rejected, bounded tuple in the second position still routes to LP. Docs - Rewrite the "sign parameter" section of doc/piecewise-linear-constraints.rst for per-tuple sign. Update the comparison table, examples, and the release notes entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/piecewise-linear-constraints.rst | 240 ++++++------- doc/release_notes.rst | 6 +- linopy/piecewise.py | 491 +++++++++++++++------------ test/test_piecewise_constraints.py | 143 ++++---- test/test_piecewise_feasibility.py | 237 +------------ 5 files changed, 461 insertions(+), 656 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 31aca446..a1331dba 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -39,14 +39,15 @@ Quick Start # (pure LP with chord constraints when the curve's curvature matches # the requested sign; SOS2/incremental otherwise). m.add_piecewise_formulation( - (fuel, [0, 20, 30, 35]), # bounded output listed FIRST - (power, [0, 10, 20, 30]), # input always on the curve - sign="<=", + (fuel, [0, 20, 30, 35], "<="), # bounded by the curve + (power, [0, 10, 20, 30]), # pinned to the curve ) -Each ``(expression, breakpoints)`` tuple pairs a variable with its breakpoint -values. All tuples share interpolation weights, so at any feasible point every -variable corresponds to the *same* point on the piecewise curve. +Each ``(expression, breakpoints[, sign])`` tuple pairs a variable with its +breakpoint values, and optionally marks it as bounded by the curve (``"<="`` +or ``">="``) instead of pinned to it. All tuples share interpolation weights, +so at any feasible point every variable corresponds to the *same* point on +the piecewise curve. API @@ -58,18 +59,17 @@ API .. code-block:: python m.add_piecewise_formulation( - (expr1, breakpoints1), - (expr2, breakpoints2), + (expr1, breakpoints1), # pinned (sign defaults to "==") + (expr2, breakpoints2, "<="), # or with an explicit sign ..., - sign="==", # "==", "<=", or ">=" method="auto", # "auto", "sos2", "incremental", or "lp" - active=None, # binary variable to gate the constraint - name=None, # base name for generated variables/constraints + active=None, # binary variable to gate the constraint + name=None, # base name for generated variables/constraints ) -Creates auxiliary variables and constraints that enforce either an equality -(``sign="=="``, default) or a one-sided bound (``sign="<="`` / ``">="``) of the -first expression by the piecewise function of the rest. +Creates auxiliary variables and constraints that enforce either a joint +equality (all tuples on the curve, the default) or a one-sided bound +(at most one tuple bounded by the curve, the rest pinned). ``breakpoints`` and ``segments`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -85,110 +85,94 @@ Factory functions that create DataArrays with the correct dimension names: linopy.segments({"gen1": [(0, 10)], "gen2": [(0, 80)]}, dim="gen") -The ``sign`` parameter — equality vs inequality ------------------------------------------------- +Per-tuple sign — equality vs inequality +---------------------------------------- -The ``sign`` argument of ``add_piecewise_formulation`` chooses whether all -expressions are locked onto the curve or whether the first one is bounded: +By default each tuple's expression is **pinned** to the piecewise curve. +Pass a third tuple element (``"<="`` or ``">="``) to mark a single +expression as **bounded** by the curve — it can undershoot (``"<="``) or +overshoot (``">="``) the interpolated value, while every other tuple +stays pinned. -- ``sign="=="`` (default): **every** expression lies *exactly* on the - piecewise curve — joint equality. All tuples are symmetric. The feasible - region is a 1-D curve in N-space. -- ``sign="<="``: **N−1 tuples are pinned** to the curve (moving together along - it), and the **first** tuple's expression is **bounded above** by its - interpolated value at that shared curve position — it can undershoot. -- ``sign=">="``: same split, but the first is bounded **below** (overshoots - admitted). +.. code-block:: python + + # Joint equality (default): both expressions on the curve. + m.add_piecewise_formulation((y, y_pts), (x, x_pts)) + + # Bounded above: y <= f(x), x pinned. + m.add_piecewise_formulation((y, y_pts, "<="), (x, x_pts)) + + # Bounded below: y >= f(x), x pinned. + m.add_piecewise_formulation((y, y_pts, ">="), (x, x_pts)) + + # 3-variable equality (CHP heat/power/fuel): all three on one curve. + m.add_piecewise_formulation( + (power, p_pts), (fuel, f_pts), (heat, h_pts) + ) -Inequality relaxes **one** tuple's curve-equality into a one-sided bound. The -others keep moving together along the curve in lockstep — this is the -*first-tuple convention*. +**Restrictions:** -**What this means geometrically.** +- At most one tuple may carry a non-equality sign — a single bounded side. +- With **3 or more** tuples, all signs must be ``"=="``. The multi-input + bounded case is reserved for a future bivariate / triangulated piecewise + API. -For 2 variables (``(y, yp), (x, xp)``, ``sign="<="``), "N−1 pinned, 1 bounded" -reduces to the familiar **hypograph**: ``x`` moves along the breakpoint axis, -``y`` ranges from its lower bound up to ``f(x)``. +**Geometry.** For 2 variables with ``sign="<="`` on a concave curve +:math:`f`, the feasible region is the **hypograph** of :math:`f` on its +domain: -For 3+ variables, the N−1 pinned tuples are **jointly constrained** to a -single segment position on the piecewise curve. In a CHP characteristic -``(power, fuel, heat)`` with ``sign="<="``, ``fuel`` and ``heat`` trace the -curve simultaneously: specifying ``fuel = 85`` determines the segment -position, which in turn fixes ``heat`` to its curve value at that position. -Assigning ``heat`` to any value inconsistent with the same segment renders -the system infeasible. +.. math:: + + \{ (x, y) \ :\ x_0 \le x \le x_n,\ y \le f(x) \}. -The feasible region in N-space is a 2-dimensional manifold: the 1-D -parametric curve at its upper boundary (for ``"<="``), extended along the -first tuple's axis down to that variable's lower bound. +For convex :math:`f` with ``sign=">="`` it is the **epigraph**. Mismatched +sign + curvature (convex + ``"<="``, or concave + ``">="``) describes a +*non-convex* region — ``method="auto"`` falls back to SOS2/incremental +and ``method="lp"`` raises. -**Choice of bounded tuple.** The first tuple should correspond to a +**Choice of bounded tuple.** The bounded tuple should correspond to a quantity with a mechanism for below-curve operation — typically a controllable dissipation path: heat rejection via cooling tower (also -called *thermal curtailment*), electrical curtailment, or emissions after -post-treatment. Placing a consumption-side variable such as fuel intake -in the bounded position yields a valid but **loose** formulation: the -characteristic curve fixes fuel draw at a given load, so ``sign="<="`` on +called *thermal curtailment*), electrical curtailment, or emissions +after post-treatment. Marking a consumption-side variable such as fuel +intake as bounded yields a valid but **loose** formulation: the +characteristic curve fixes fuel draw at a given load, so ``"<="`` on fuel admits operating points the plant cannot physically realise. An objective that rewards lower fuel may then find a non-physical optimum — safe only when no such objective pressure exists. -Relatedly, inequality formulations can also be **faster to solve**: with -2 variables and matching curvature, ``method="auto"`` dispatches to the -pure-LP chord formulation (no SOS2, no binaries). For N≥3 the solver -still reaches for SOS2/incremental, but the relaxed feasible region -often tightens the LP relaxation and reduces branch-and-bound work. -Choose ``sign="=="`` when you want strict curve adherence (the -tightest feasible region) and ``sign="<="`` / ``">="`` when either the -physics admits dissipation or the speedup is worth the relaxation. - **When is a one-sided bound wanted?** -For *continuous* curves, the main reason to reach for ``sign="<="`` / -``">="`` is to unlock the **LP chord formulation** — no SOS2, no -binaries, just pure LP. On a convex/concave curve with a matching sign, -the chord inequalities are as tight as SOS2, so you get the same optimum -with a cheaper model. - -For *disjunctive* curves (``segments(...)``), ``sign`` is a first-class -tool in its own right: disconnected operating regions with a bounded -output, always exact regardless of segment curvature (see the +For *continuous* curves, the main reason to reach for ``"<="`` / ``">="`` +is to unlock the **LP chord formulation** — no SOS2, no binaries, just +pure LP. On a convex/concave curve with a matching sign, the chord +inequalities are as tight as SOS2, so you get the same optimum with a +cheaper model. Inequality formulations also tighten the LP relaxation +of SOS2/incremental, which can reduce branch-and-bound work even when +LP itself is not applicable. + +For *disjunctive* curves (``segments(...)``), the per-tuple sign is a +first-class tool in its own right: disconnected operating regions with a +bounded output, always exact regardless of segment curvature (see the disjunctive section below). -Beyond that: fuel-on-efficiency-envelope modelling (extra burn above the -curve is admissible, cost is still bounded), emissions caps where the curve -is itself a convex overestimator, or any situation where the curve bounds a -variable that need not sit *on* it. - If the curvature doesn't match the sign (convex + ``"<="``, or concave + ``">="``), LP is not applicable — ``method="auto"`` falls back to -SOS2/incremental with the signed output link, which gives a valid but -much more expensive model. In that case prefer ``sign="=="`` unless you -genuinely need the one-sided semantics; the equality formulation is -typically simpler to reason about and no more expensive than the SOS2 -inequality variant. - -**Math (2-variable ``sign="<="``, concave :math:`f`).** The feasible region is -the **hypograph** of :math:`f` restricted to the breakpoint range: - -.. math:: - - \{ (x, y) \ :\ x_0 \le x \le x_n,\ y \le f(x) \}. - -For convex :math:`f` with ``sign=">="``, the feasible region is the epigraph. -Mismatched sign+curvature (convex + ``<=``, or concave + ``>=``) describes a -*non-convex* region — ``method="auto"`` will fall back to SOS2/incremental and -``method="lp"`` will raise. See the -:doc:`piecewise-inequality-bounds-tutorial` notebook for a full walkthrough. +SOS2/incremental with the signed link, which gives a valid but much +more expensive model. In that case prefer ``"=="`` unless you genuinely +need the one-sided semantics. See the +:doc:`piecewise-inequality-bounds-tutorial` notebook for a full +walkthrough. .. warning:: - With ``sign="<="`` and ``active=0``, the output is only bounded **above** by - ``0`` — the lower side still comes from the output variable's own lower - bound. In the common case of non-negative outputs (fuel, cost, heat), set - ``lower=0`` on that variable: combined with the ``y ≤ 0`` constraint from - deactivation, this forces ``y = 0`` automatically. See the docstring for - the full recipe. + With a bounded tuple and ``active=0``, the output is only forced to + ``0`` on the signed side — the complementary bound still comes from + the output variable's own lower/upper bound. In the common case of + non-negative outputs (fuel, cost, heat), set ``lower=0`` on that + variable: combined with the ``y ≤ 0`` constraint from deactivation, + this forces ``y = 0`` automatically. See the docstring for the + full recipe. Breakpoint Construction @@ -275,13 +259,12 @@ For disconnected operating regions (e.g. forbidden zones), use ``segments()``: ) The disjunctive formulation is selected automatically when breakpoints have a -segment dimension. ``sign="<="`` / ``">="`` also works here; the signed link -is applied to the first tuple as usual. +segment dimension. A bounded tuple (``"<="`` / ``">="``) also works here. N-variable linking ~~~~~~~~~~~~~~~~~~ -Link any number of variables through shared breakpoints: +Link any number of variables through shared breakpoints (joint equality): .. code-block:: python @@ -291,9 +274,10 @@ Link any number of variables through shared breakpoints: (heat, [0, 25, 55, 95]), ) -With ``sign="=="`` (default) all variables are symmetric. With a non-equality -sign the first tuple is the bounded output and the rest are forced to -equality. +All variables are symmetric here; every feasible point is the same +``λ``-weighted combination of breakpoints across all three. With 3 or +more tuples, only ``"=="`` signs are accepted — see the per-tuple sign +section above. Formulation Methods @@ -328,16 +312,16 @@ At-a-glance comparison: - Connected - Connected - Disconnected - * - Supported ``sign`` - - ``==``, ``<=``, ``>=`` - - ``==``, ``<=``, ``>=`` - - ``<=``, ``>=`` only - - ``==``, ``<=``, ``>=`` + * - Supported per-tuple sign + - all ``==`` or one ``<=``/``>=`` + - all ``==`` or one ``<=``/``>=`` + - one ``<=`` or ``>=`` (required) + - all ``==`` or one ``<=``/``>=`` * - Number of tuples - - Any (≥ 2) - - Any (≥ 2) + - ≥ 2 (3+ requires all ``==``) + - ≥ 2 (3+ requires all ``==``) - Exactly 2 - - Any (≥ 2) + - ≥ 2 (3+ requires all ``==``) * - Breakpoint order - Any - Strictly monotonic @@ -381,9 +365,9 @@ Works for any breakpoint ordering. Introduces interpolation weights The SOS2 constraint ensures at most two adjacent :math:`\lambda_i` are non-zero, so every expression is interpolated within the same segment. -With ``sign != "=="`` the input tuples still use the equality above; the -**first** tuple's link is replaced by a one-sided ``e_1 \ \text{sign}\ \sum_i -\lambda_i B_{1,i}`` constraint. +With a bounded tuple, the pinned tuples still use the equality above; the +bounded tuple's link is replaced by a one-sided ``e_b \ \text{sign}\ \sum_i +\lambda_i B_{b,i}`` constraint. .. note:: @@ -408,8 +392,8 @@ For **strictly monotonic** breakpoints. Uses fill-fraction variables &e_j = B_{j,0} + \sum_{i=1}^{n} \delta_i \, (B_{j,i} - B_{j,i-1}) -With ``sign != "=="`` the same sign split as SOS2 applies: inputs use the -equality above; the first tuple's link uses the requested sign. +With a bounded tuple the same split as SOS2 applies: pinned tuples use the +equality above; the bounded tuple's link uses the requested sign. .. code-block:: python @@ -445,12 +429,12 @@ this and falls back; ``method="lp"`` raises. .. code-block:: python # y <= f(x) on a concave f — auto picks LP - m.add_piecewise_formulation((y, yp), (x, xp), sign="<=") + m.add_piecewise_formulation((y, yp, "<="), (x, xp)) # Or explicitly: - m.add_piecewise_formulation((y, yp), (x, xp), sign="<=", method="lp") + m.add_piecewise_formulation((y, yp, "<="), (x, xp), method="lp") -**Not supported with** ``method="lp"``: ``sign="=="``, more than 2 tuples, +**Not supported with** ``method="lp"``: all-equality, more than 2 tuples, and ``active``. ``method="auto"`` falls back to SOS2/incremental in all three cases. @@ -476,14 +460,14 @@ indicators :math:`z_k` select exactly one segment; SOS2 applies within it: No big-M constants are needed, giving a tight LP relaxation. -**Disjunctive + ``sign``.** ``sign="<="`` / ``">="`` works here too, -applied to the first tuple exactly as for the continuous methods. -Because the disjunctive machinery already carries a per-segment binary, -there is **no curvature requirement** on the segments — inequality is -always exact on the hypograph (or epigraph) of the active segment, -whatever its slope pattern. This makes disjunctive + sign a first-class -tool for "bounded output on disconnected operating regions" that -``method="lp"`` cannot handle. +**Disjunctive + bounded tuple.** A per-tuple ``"<="`` / ``">="`` works +here too, applied to the bounded tuple exactly as for the continuous +methods. Because the disjunctive machinery already carries a +per-segment binary, there is **no curvature requirement** on the +segments — inequality is always exact on the hypograph (or epigraph) of +the active segment, whatever its slope pattern. This makes disjunctive +plus a bounded tuple a first-class tool for "bounded output on +disconnected operating regions" that ``method="lp"`` cannot handle. Advanced Features @@ -512,11 +496,11 @@ Not supported with ``method="lp"``. .. note:: - With a non-equality ``sign``, deactivation only pushes the signed bound to + With a bounded tuple, deactivation only pushes the signed bound to ``0`` — the complementary side comes from the output variable's own lower/upper bound. Set ``lower=0`` on naturally non-negative outputs - (fuel, cost, heat) to pin the output to zero on deactivation. See the - ``sign`` section above for details. + (fuel, cost, heat) to pin the output to zero on deactivation. See + the per-tuple sign section above for details. Auto-broadcasting ~~~~~~~~~~~~~~~~~ diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 621ea0ca..34292d36 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -11,11 +11,11 @@ Upcoming Version - Comparison operators (``==``, ``<=``, ``>=``) fill missing RHS coords with NaN (no constraint created) - Fixes crash on ``subset + var`` / ``subset + expr`` reverse addition - Fixes superset DataArrays expanding result coords beyond the variable's coordinate space -* Add ``add_piecewise_formulation()`` for piecewise linear equality constraints with SOS2, incremental, and disjunctive formulations: ``m.add_piecewise_formulation((power, x_pts), (fuel, y_pts))``. Supports N-variable linking (e.g. CHP with fuel/power/heat) and per-entity breakpoints. ``method="auto"`` picks the cheapest correct formulation automatically. The API is newly added and emits an :class:`linopy.EvolvingAPIWarning` to signal that details (e.g. the ``sign``/first-tuple convention, ``active`` + non-equality sign semantics) may be refined in minor releases — feedback and use cases at https://github.com/PyPSA/linopy/issues shape what stabilises. Silence with ``warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)``. -* Add one-sided piecewise bounds via the ``sign`` parameter on ``add_piecewise_formulation``: ``sign="<="`` / ``">="`` applies the bound to the first tuple (first-tuple convention). On convex/concave curves with a matching sign, ``method="auto"`` dispatches to a pure-LP chord formulation (``method="lp"``) with no auxiliary variables and automatic domain bounds on the input. Mismatched curvature+sign is detected and falls back to SOS2/incremental with an explanatory info log. +* Add ``add_piecewise_formulation()`` for piecewise linear constraints with SOS2, incremental, and disjunctive formulations: ``m.add_piecewise_formulation((power, x_pts), (fuel, y_pts))``. Supports N-variable linking (e.g. CHP with fuel/power/heat) and per-entity breakpoints. ``method="auto"`` picks the cheapest correct formulation automatically. The API is newly added and emits an :class:`linopy.EvolvingAPIWarning` to signal that details (e.g. the per-tuple sign convention, ``active`` + non-equality sign semantics) may be refined in minor releases — feedback and use cases at https://github.com/PyPSA/linopy/issues shape what stabilises. Silence with ``warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)``. +* Add one-sided piecewise bounds via a per-tuple sign on ``add_piecewise_formulation``: append ``"<="`` or ``">="`` as a third tuple element — e.g. ``(fuel, y_pts, "<=")`` — to mark that expression as bounded by the curve while the others remain pinned. At most one tuple may carry a non-equality sign; with 3 or more tuples all signs must be ``"=="``. On convex/concave curves with a matching sign, ``method="auto"`` dispatches to a pure-LP chord formulation (``method="lp"``) with no auxiliary variables and automatic domain bounds on the input. Mismatched curvature+sign is detected and falls back to SOS2/incremental with an explanatory info log. * Add unit-commitment gating via the ``active`` parameter on ``add_piecewise_formulation``: a binary variable that, when zero, forces all auxiliary variables (and thus the linked expressions) to zero. Works with the SOS2, incremental, and disjunctive methods. * Surface formulation metadata on the returned ``PiecewiseFormulation``: ``.method`` (resolved method name) and ``.convexity`` (``"convex"`` / ``"concave"`` / ``"linear"`` / ``"mixed"`` when well-defined). Both persist across netCDF round-trip. -* Add ``tangent_lines()`` as a low-level helper that returns per-segment chord expressions as a ``LinearExpression`` — no variables created. Most users should prefer ``add_piecewise_formulation(..., sign="<=")``, which builds on this helper and adds domain bounds and curvature validation. +* Add ``tangent_lines()`` as a low-level helper that returns per-segment chord expressions as a ``LinearExpression`` — no variables created. Most users should prefer ``add_piecewise_formulation`` with a bounded tuple ``(y, y_pts, "<=")``, which builds on this helper and adds domain bounds and curvature validation. * Add ``linopy.breakpoints()`` factory for convenient breakpoint construction from lists, Series, DataFrames, DataArrays, or dicts. Supports slopes mode. * Add ``linopy.segments()`` factory for disjunctive (disconnected) breakpoints. * Add ``slopes_to_points()`` utility for converting segment slopes to breakpoint y-coordinates. diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 313f7a0a..fba8f4fa 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -481,8 +481,8 @@ def _tangent_lines_impl( """ Chord-expression math — the body of ``tangent_lines`` without the :class:`EvolvingAPIWarning`. Called internally by ``_add_lp`` so a - single ``add_piecewise_formulation(sign="<=")`` emits exactly one - warning, not two. + single ``add_piecewise_formulation((y, y_pts, "<="), (x, x_pts))`` + emits exactly one warning, not two. """ from linopy.expressions import LinearExpression as LinExpr from linopy.variables import Variable @@ -523,12 +523,13 @@ def tangent_lines( is the chord of one segment: :math:`m_k \cdot x + c_k`. No auxiliary variables are created. - For most users: prefer :func:`add_piecewise_formulation` with - ``sign="<="`` / ``">="`` — it builds on this helper and adds the - ``x ∈ [x_min, x_max]`` domain bound plus a curvature-vs-sign check - that catches the "wrong region" case. Use ``tangent_lines`` directly - only when you need to compose the chord expressions manually (e.g. with - other linear terms, or without the domain bound). + For most users: prefer :func:`add_piecewise_formulation` with a + bounded tuple ``(y, y_pts, "<=")`` / ``(y, y_pts, ">=")`` — it builds + on this helper and adds the ``x ∈ [x_min, x_max]`` domain bound plus + a curvature-vs-sign check that catches the "wrong region" case. Use + ``tangent_lines`` directly only when you need to compose the chord + expressions manually (e.g. with other linear terms, or without the + domain bound). .. code-block:: python @@ -737,28 +738,30 @@ def _broadcast_points( def add_piecewise_formulation( model: Model, - *pairs: tuple[LinExprLike, BreaksLike], - sign: Literal["==", "<=", ">="] = "==", + *pairs: tuple[LinExprLike, BreaksLike] + | tuple[LinExprLike, BreaksLike, Literal["==", "<=", ">="]], method: Literal["sos2", "incremental", "lp", "auto"] = "auto", active: LinExprLike | None = None, name: str | None = None, + **kwargs: object, ) -> PiecewiseFormulation: r""" Add piecewise linear constraints. - Each positional argument is a ``(expression, breakpoints)`` tuple. - All expressions are linked through shared interpolation weights so - that every operating point lies on the same segment of the piecewise - curve. + Each positional argument is a ``(expression, breakpoints)`` tuple, or + ``(expression, breakpoints, sign)`` to mark that expression as bounded + by the piecewise curve rather than pinned to it. All expressions are + linked through shared interpolation weights so that every operating + point lies on the same segment of the piecewise curve. - Example — 2 variables:: + Example — 2 variables (joint equality, the default):: m.add_piecewise_formulation( (power, [0, 30, 60, 100]), (fuel, [0, 36, 84, 170]), ) - Example — 3 variables (CHP plant):: + Example — 3 variables, CHP plant (joint equality):: m.add_piecewise_formulation( (power, [0, 30, 60, 100]), @@ -766,45 +769,55 @@ def add_piecewise_formulation( (heat, [0, 25, 55, 95]), ) - **Sign — inequality bounds:** + **Per-tuple sign — inequality bounds:** - The ``sign`` parameter follows the *first-tuple convention*: + Add ``"<="`` or ``">="`` as a third tuple element to mark a single + expression as bounded by the curve instead of pinned to it. The + remaining tuples are still forced to equality (input on the curve). + Reads directly as the relation it encodes: - - ``sign="=="`` (default): all expressions must lie exactly on the - piecewise curve (joint equality). - - ``sign="<="``: the **first** tuple's expression is **bounded above** - by its interpolated value; all other tuples are forced to equality - (inputs on the curve). Reads as *"first expression ≤ f(the rest)"*. - - ``sign=">="``: same but the first is bounded **below**. + .. code-block:: python + + # fuel <= f(power) — concave curve, bounded above + m.add_piecewise_formulation( + (fuel, y_pts, "<="), + (power, x_pts), + ) + + # cost >= g(load) — convex curve, bounded below + m.add_piecewise_formulation( + (cost, y_pts, ">="), + (load, x_pts), + ) For 2-variable inequality on convex/concave curves, ``method="auto"`` automatically selects a pure-LP tangent-line formulation (no auxiliary variables). Non-convex curves fall back to SOS2/incremental with the - sign applied to the first tuple's link constraint. + sign applied to the bounded tuple's link constraint. - Example — ``fuel ≤ f(power)`` on a concave curve:: + **Restrictions on per-tuple sign:** - m.add_piecewise_formulation( - (fuel, y_pts), # bounded output, listed first - (power, x_pts), # input, always equality - sign="<=", - ) + - At most one tuple may carry a non-equality sign. All other tuples + default to ``"=="``. + - With **3 or more** tuples, all signs must be ``"=="`` (the + multi-input bounded case is not supported yet — the natural reading + ``z ≥ f(x, y)`` belongs to a future bivariate / triangulated + piecewise API). Parameters ---------- - *pairs : tuple of (expression, breakpoints) + *pairs : tuple of (expression, breakpoints) or (expression, breakpoints, sign) Each pair links an expression (Variable or LinearExpression) to - its breakpoint values. At least two pairs are required. With - ``sign != EQUAL`` the **first** pair is the bounded output; all - later pairs are treated as inputs forced to equality. - sign : {"==", "<=", ">="}, default "==" - Constraint sign applied to the *first* tuple's link constraint. - Later tuples always use equality. See description above. + its breakpoint values. An optional third element ``"<="`` or + ``">="`` marks that expression as bounded by the curve; if + omitted, the expression is pinned (``"=="``). At least two pairs + are required; at most one may carry a non-equality sign; with + 3+ pairs all signs must be ``"=="``. method : {"auto", "sos2", "incremental", "lp"}, default "auto" Formulation method. ``"lp"`` uses tangent lines (pure LP, no variables) and requires - ``sign != EQUAL`` plus a matching-convexity curve with exactly - two tuples. + exactly one tuple with ``"<="`` or ``">="`` plus a matching-curvature + curve with exactly two tuples. ``"auto"`` picks ``"lp"`` when applicable, otherwise ``"incremental"`` (monotonic breakpoints) or ``"sos2"``. active : Variable or LinearExpression, optional @@ -812,9 +825,9 @@ def add_piecewise_formulation( ``active=0``, all auxiliary variables are forced to zero. Not supported with ``method="lp"``. - With ``sign="=="`` (the default), the output is then pinned to - ``0``. With ``sign="<="`` / ``">="``, deactivation only pushes - the signed bound to ``0`` (the output is ≤ 0 or ≥ 0 + With all-equality tuples (the default), the output is then pinned + to ``0``. With a bounded tuple (``"<="`` / ``">="``), deactivation + only pushes the signed bound to ``0`` (the output is ≤ 0 or ≥ 0 respectively) — the complementary bound still comes from the output variable's own lower/upper. In the common case where the output is naturally non-negative (fuel, cost, heat, …), @@ -834,14 +847,14 @@ def add_piecewise_formulation( ----- EvolvingAPIWarning ``add_piecewise_formulation`` is a newly-added API; details such - as the ``sign``/first-tuple convention and ``active`` + non-equality + as the per-tuple sign convention and ``active`` + non-equality sign semantics may be refined based on user feedback. Silence with ``warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)``. """ warnings.warn( "piecewise: add_piecewise_formulation is a new API; some details " - "(e.g. the sign/first-tuple convention, active+sign semantics) " + "(e.g. the per-tuple sign convention, active+sign semantics) " "may be refined in minor releases. Please share your use cases " "or concerns at https://github.com/PyPSA/linopy/issues — your " "feedback shapes what stabilises. Silence with " @@ -850,82 +863,142 @@ def add_piecewise_formulation( stacklevel=2, ) - # Normalize sign (accept "==" or "=" for equality, etc.). The Literal - # annotation above covers the user-facing forms; after normalization - # ``sign`` holds one of the canonical values in :data:`SIGNS`. - sign = sign_replace_dict.get(sign, sign) # type: ignore[assignment] - if sign not in SIGNS: - raise ValueError(f"sign must be one of {sorted(SIGNS)}, got '{sign}'") + # Migration helper: explicit error for the removed sign= keyword. + if "sign" in kwargs: + raise TypeError( + "The `sign=` keyword has been removed from add_piecewise_formulation. " + "Specify the sign per-tuple as a third tuple element, e.g. " + "`(fuel, y_pts, '<=')` instead of `sign='<='`. " + "See doc/piecewise-linear-constraints.rst." + ) + if kwargs: + raise TypeError( + "add_piecewise_formulation() got unexpected keyword argument(s): " + f"{sorted(kwargs)}" + ) + if method not in PWL_METHODS: raise ValueError(f"method must be one of {sorted(PWL_METHODS)}, got '{method}'") - if method == "lp" and sign == EQUAL: - raise ValueError("method='lp' requires sign='<=' or '>='.") if len(pairs) < 2: raise TypeError( "add_piecewise_formulation() requires at least 2 " - "(expression, breakpoints) pairs." + "(expression, breakpoints[, sign]) pairs." ) + # Parse and normalise per-tuple signs. Each pair is either + # (expr, bp) — sign defaults to "==" — or (expr, bp, sign). + parsed: list[tuple[LinExprLike, BreaksLike, str]] = [] for i, pair in enumerate(pairs): - if not isinstance(pair, tuple) or len(pair) != 2: + if not isinstance(pair, tuple) or len(pair) not in (2, 3): raise TypeError( - f"Argument {i + 1} must be a (expression, breakpoints) tuple, " - f"got {type(pair)}." + f"Argument {i + 1} must be a (expression, breakpoints) " + f"or (expression, breakpoints, sign) tuple, got {pair!r}." ) + if len(pair) == 2: + expr, bp = pair + tuple_sign: str = EQUAL + else: + expr, bp, raw_sign = pair + tuple_sign = sign_replace_dict.get(raw_sign, raw_sign) + if tuple_sign not in SIGNS: + raise ValueError( + f"Argument {i + 1}: sign must be one of " + f"{sorted(SIGNS)}, got {raw_sign!r}." + ) + parsed.append((expr, bp, tuple_sign)) + + # At most one non-equality sign; with 3+ tuples, none. + bounded_positions = [i for i, p in enumerate(parsed) if p[2] != EQUAL] + if len(bounded_positions) > 1: + raise ValueError( + "At most one tuple may carry a non-equality sign; got " + f"{len(bounded_positions)} (positions {bounded_positions})." + ) + if len(parsed) >= 3 and bounded_positions: + raise ValueError( + "Non-equality signs are not supported with 3+ tuples. " + "Use sign='==' on all tuples (the default), or reduce to 2 tuples. " + "The multi-input bounded case is reserved for a future " + "bivariate / triangulated piecewise API." + ) + + signed_idx: int | None + if bounded_positions: + bidx = bounded_positions[0] + signed_idx = bidx + sign: str = parsed[bidx][2] + else: + signed_idx = None + sign = EQUAL - # Coerce all breakpoints. Drop scalar coordinates (e.g. left over - # from bp.sel(var="power")) so they don't conflict when stacking. - coerced: list[tuple[LinExprLike, DataArray]] = [] - for expr, bp in pairs: + if method == "lp" and sign == EQUAL: + raise ValueError( + "method='lp' requires exactly one tuple with sign='<=' or '>='." + ) + + coerced_bps: list[DataArray] = [] + for _, bp, _s in parsed: if not isinstance(bp, DataArray): bp = _coerce_breaks(bp) scalar_coords = [c for c in bp.coords if c not in bp.dims] if scalar_coords: bp = bp.drop_vars(scalar_coords) - coerced.append((expr, bp)) - - # Check for disjunctive (segment dimension) on first pair - first_bp = coerced[0][1] - disjunctive = SEGMENT_DIM in first_bp.dims + coerced_bps.append(bp) - # Validate all breakpoint pairs have compatible shapes. - # Checking each against the first is sufficient since the shape checks are transitive. - for i in range(1, len(coerced)): - _validate_breakpoint_shapes(first_bp, coerced[i][1]) + disjunctive = SEGMENT_DIM in coerced_bps[0].dims + for i in range(1, len(coerced_bps)): + _validate_breakpoint_shapes(coerced_bps[0], coerced_bps[i]) - # Broadcast all breakpoints to match all expression dimensions - all_exprs = [expr for expr, _ in coerced] + raw_exprs = [expr for expr, _, _ in parsed] bp_list = [ - _broadcast_points(bp, *all_exprs, disjunctive=disjunctive) for _, bp in coerced + _broadcast_points(bp, *raw_exprs, disjunctive=disjunctive) for bp in coerced_bps ] - # Compute combined mask from all breakpoints combined_null = bp_list[0].isnull() for bp in bp_list[1:]: combined_null = combined_null | bp.isnull() bp_mask = ~combined_null if bool(combined_null.any()) else None - # Name if name is None: name = f"pwl{model._pwlCounter}" model._pwlCounter += 1 - # Build link dimension coordinates from variable names from linopy.variables import Variable link_coords: list[str] = [] - for i, expr in enumerate(all_exprs): + for i, expr in enumerate(raw_exprs): if isinstance(expr, Variable) and expr.name: link_coords.append(expr.name) else: link_coords.append(str(i)) - # Convert expressions to LinearExpressions - lin_exprs = [_to_linexpr(expr) for expr in all_exprs] + lin_exprs = [_to_linexpr(expr) for expr in raw_exprs] active_expr = _to_linexpr(active) if active is not None else None - # Snapshot existing names to detect what the formulation adds + if signed_idx is None: + inputs = _PwlInputs( + pinned_exprs=lin_exprs, + pinned_bps=bp_list, + pinned_coords=link_coords, + bounded_expr=None, + bounded_bp=None, + bounded_coord=None, + bounded_sign=EQUAL, + bp_mask=bp_mask, + ) + else: + inputs = _PwlInputs( + pinned_exprs=[e for j, e in enumerate(lin_exprs) if j != signed_idx], + pinned_bps=[b for j, b in enumerate(bp_list) if j != signed_idx], + pinned_coords=[c for j, c in enumerate(link_coords) if j != signed_idx], + bounded_expr=lin_exprs[signed_idx], + bounded_bp=bp_list[signed_idx], + bounded_coord=link_coords[signed_idx], + bounded_sign=sign, + bp_mask=bp_mask, + ) + vars_before = set(model.variables) cons_before = set(model.constraints) @@ -938,32 +1011,11 @@ def add_piecewise_formulation( raise ValueError( "method='lp' is not supported for disjunctive (segment) breakpoints" ) - _add_disjunctive( - model, - name, - lin_exprs, - bp_list, - link_coords, - bp_mask, - sign, - active_expr, - ) + _add_disjunctive(model, name, inputs, active_expr) resolved_method = "sos2" else: - # Continuous: stack into N-variable formulation - resolved_method = _add_continuous( - model, - name, - lin_exprs, - bp_list, - link_coords, - bp_mask, - method, - sign, - active_expr, - ) + resolved_method = _add_continuous(model, name, inputs, method, active_expr) - # Collect newly created variable and constraint names new_vars = [n for n in model.variables if n not in vars_before] new_cons = [n for n in model.constraints if n not in cons_before] @@ -974,15 +1026,19 @@ def add_piecewise_formulation( name, resolved_method, sign, - len(pairs), - "" if len(pairs) == 1 else "s", + inputs.n_tuples, + "" if inputs.n_tuples == 1 else "s", ) - # Compute convexity when well-defined: exactly two tuples (y, x), - # non-disjunctive, and strictly monotonic x breakpoints. convexity: Literal["convex", "concave", "linear", "mixed"] | None = None - if len(bp_list) == 2 and not disjunctive: - x_pts, y_pts = bp_list[1], bp_list[0] + if inputs.n_tuples == 2 and not disjunctive: + if inputs.is_equality: + x_pts = inputs.pinned_bps[1] + y_pts: DataArray = inputs.pinned_bps[0] + else: + assert inputs.bounded_bp is not None + x_pts = inputs.pinned_bps[0] + y_pts = inputs.bounded_bp if _check_strict_monotonicity(x_pts): convexity = _detect_convexity(x_pts, y_pts) @@ -1010,30 +1066,74 @@ def _stack_along_link( return xr.concat(expanded, dim=link_dim, coords="minimal") # type: ignore +@dataclass +class _PwlInputs: + """ + Categorised piecewise inputs (post-coercion, post-broadcast). + + ``pinned_*`` are the equality tuples in the user's original order. + ``bounded_*`` is the single non-equality tuple, or ``None``. + ``bounded_sign`` is ``EQUAL`` iff ``bounded_expr is None``. + """ + + pinned_exprs: list[LinearExpression] + pinned_bps: list[DataArray] + pinned_coords: list[str] + bounded_expr: LinearExpression | None + bounded_bp: DataArray | None + bounded_coord: str | None + bounded_sign: str + bp_mask: DataArray | None + link_dim: str = "_pwl_var" + + @property + def is_equality(self) -> bool: + return self.bounded_expr is None + + @property + def n_tuples(self) -> int: + return len(self.pinned_exprs) + (0 if self.is_equality else 1) + + def all_bps(self) -> list[DataArray]: + if self.bounded_bp is None: + return list(self.pinned_bps) + return [self.bounded_bp, *self.pinned_bps] + + def all_coords(self) -> list[str]: + if self.bounded_coord is None: + return list(self.pinned_coords) + return [self.bounded_coord, *self.pinned_coords] + + def all_exprs(self) -> list[LinearExpression]: + if self.bounded_expr is None: + return list(self.pinned_exprs) + return [self.bounded_expr, *self.pinned_exprs] + + def _lp_eligibility( - lin_exprs: list[LinearExpression], - bp_list: list[DataArray], - sign: str, + inputs: _PwlInputs, active: LinearExpression | None, ) -> tuple[bool, str]: """ Check whether LP tangent-lines dispatch is applicable. - Returns ``(True, "")`` if LP is applicable, else ``(False, reason)`` - with a short string describing why. Used for both auto-dispatch - and for an informational log when LP is skipped. + Returns ``(True, "")`` if LP is applicable, else ``(False, reason)``. """ - if len(lin_exprs) != 2: - return False, f"{len(lin_exprs)} expressions (LP supports only 2)" + if inputs.n_tuples != 2: + return False, f"{inputs.n_tuples} expressions (LP supports only 2)" + if inputs.is_equality: + return False, "all tuples are equality (LP needs one bounded tuple)" if active is not None: return False, "active=... is not supported by LP" - x_pts = bp_list[1] - y_pts = bp_list[0] + assert inputs.bounded_bp is not None # narrowed by is_equality check + x_pts = inputs.pinned_bps[0] + y_pts = inputs.bounded_bp if not _check_strict_monotonicity(x_pts): return False, "x breakpoints are not strictly monotonic" if not _has_trailing_nan_only(x_pts): return False, "x breakpoints contain non-trailing NaN" convexity = _detect_convexity(x_pts, y_pts) + sign = inputs.bounded_sign if sign == LESS_EQUAL and convexity not in ("concave", "linear"): return False, f"sign='<=' needs concave/linear curvature, got '{convexity}'" if sign == GREATER_EQUAL and convexity not in ("convex", "linear"): @@ -1044,14 +1144,7 @@ def _lp_eligibility( @dataclass class _PwlLinks: """ - Packaged link expressions for a SOS2/incremental/disjunctive builder. - - ``stacked_bp`` spans *all* tuples — used to size lambda/delta variables. - ``eq_expr`` / ``eq_bp`` form the equality link (stacks all tuples when - ``sign == "=="``, inputs-only otherwise; may be ``None`` if there are no - inputs on the equality side). - ``signed_expr`` / ``signed_bp`` are the first tuple's output-side link - (``None`` iff ``sign == "=="``). + Stacked link expressions consumed by SOS2/incremental/disjunctive builders. """ stacked_bp: DataArray @@ -1064,93 +1157,82 @@ class _PwlLinks: signed_bp: DataArray | None -def _build_links( - model: Model, - lin_exprs: list[LinearExpression], - bp_list: list[DataArray], - link_coords: list[str], - link_dim: str, - sign: str, - bp_mask: DataArray | None, -) -> _PwlLinks: - """ - Split (or stack) ``lin_exprs``/``bp_list`` into the equality and - signed link components dictated by ``sign``. - """ +def _build_links(model: Model, inputs: _PwlInputs) -> _PwlLinks: + """Stack ``inputs`` into the link representation.""" from linopy.expressions import LinearExpression - stacked_bp = _stack_along_link(bp_list, link_coords, link_dim) + stacked_bp = _stack_along_link( + inputs.all_bps(), inputs.all_coords(), inputs.link_dim + ) - if sign == EQUAL: - eq_data = _stack_along_link([e.data for e in lin_exprs], link_coords, link_dim) - # eq_bp is deliberately aliased to stacked_bp here — all tuples are - # already on the equality side, so the "full stack" and the "equality - # stack" are the same array. + if inputs.is_equality: + eq_data = _stack_along_link( + [e.data for e in inputs.pinned_exprs], + inputs.pinned_coords, + inputs.link_dim, + ) return _PwlLinks( stacked_bp=stacked_bp, - link_dim=link_dim, - bp_mask=bp_mask, - sign=sign, + link_dim=inputs.link_dim, + bp_mask=inputs.bp_mask, + sign=EQUAL, eq_expr=LinearExpression(eq_data, model), eq_bp=stacked_bp, signed_expr=None, signed_bp=None, ) - signed_expr = lin_exprs[0] - signed_bp = bp_list[0] - inputs_exprs = lin_exprs[1:] - inputs_bp = bp_list[1:] - inputs_coords = link_coords[1:] - if inputs_exprs: + if inputs.pinned_exprs: eq_data = _stack_along_link( - [e.data for e in inputs_exprs], inputs_coords, link_dim + [e.data for e in inputs.pinned_exprs], + inputs.pinned_coords, + inputs.link_dim, ) eq_expr: LinearExpression | None = LinearExpression(eq_data, model) - eq_bp: DataArray | None = _stack_along_link(inputs_bp, inputs_coords, link_dim) + eq_bp: DataArray | None = _stack_along_link( + inputs.pinned_bps, inputs.pinned_coords, inputs.link_dim + ) else: eq_expr = None eq_bp = None return _PwlLinks( stacked_bp=stacked_bp, - link_dim=link_dim, - bp_mask=bp_mask, - sign=sign, + link_dim=inputs.link_dim, + bp_mask=inputs.bp_mask, + sign=inputs.bounded_sign, eq_expr=eq_expr, eq_bp=eq_bp, - signed_expr=signed_expr, - signed_bp=signed_bp, + signed_expr=inputs.bounded_expr, + signed_bp=inputs.bounded_bp, ) def _try_lp( model: Model, name: str, - lin_exprs: list[LinearExpression], - bp_list: list[DataArray], + inputs: _PwlInputs, method: str, - sign: str, active: LinearExpression | None, ) -> bool: - """ - Dispatch the LP formulation if requested/eligible. - - Returns ``True`` when LP was built (caller should return ``"lp"``), - ``False`` when the caller should fall through to SOS2/incremental. - Raises on explicit ``method="lp"`` with mismatched inputs. - """ + """Dispatch the LP formulation if requested or eligible.""" if method == "lp": - if len(lin_exprs) != 2: + if inputs.n_tuples != 2: raise ValueError( - "method='lp' requires exactly 2 (expression, breakpoints) pairs." + "method='lp' requires exactly 2 (expression, breakpoints[, sign]) pairs." ) + if inputs.is_equality: + raise ValueError("method='lp' requires one tuple with sign='<=' or '>='.") if active is not None: raise ValueError("method='lp' is not compatible with active=...") - y_pts, x_pts = bp_list[0], bp_list[1] + assert inputs.bounded_bp is not None # narrowed by is_equality check + assert inputs.bounded_expr is not None + x_pts = inputs.pinned_bps[0] + y_pts = inputs.bounded_bp if not _check_strict_monotonicity(x_pts): raise ValueError("method='lp' requires strictly monotonic x breakpoints.") convexity = _detect_convexity(x_pts, y_pts) + sign = inputs.bounded_sign if sign == LESS_EQUAL and convexity not in ("concave", "linear"): raise ValueError( "method='lp' with sign='<=' requires concave or linear " @@ -1161,20 +1243,24 @@ def _try_lp( "method='lp' with sign='>=' requires convex or linear " f"curvature; got '{convexity}'. Use method='auto'." ) - _add_lp(model, name, lin_exprs[1], lin_exprs[0], x_pts, y_pts, sign) + _add_lp( + model, name, inputs.pinned_exprs[0], inputs.bounded_expr, x_pts, y_pts, sign + ) return True - if method == "auto" and sign != EQUAL: - ok, reason = _lp_eligibility(lin_exprs, bp_list, sign, active) + if method == "auto" and not inputs.is_equality: + ok, reason = _lp_eligibility(inputs, active) if ok: + assert inputs.bounded_expr is not None + assert inputs.bounded_bp is not None _add_lp( model, name, - lin_exprs[1], - lin_exprs[0], - bp_list[1], - bp_list[0], - sign, + inputs.pinned_exprs[0], + inputs.bounded_expr, + inputs.pinned_bps[0], + inputs.bounded_bp, + inputs.bounded_sign, ) return True logger.info( @@ -1222,27 +1308,15 @@ def _resolve_sos2_vs_incremental(method: str, stacked_bp: DataArray) -> str: def _add_continuous( model: Model, name: str, - lin_exprs: list[LinearExpression], - bp_list: list[DataArray], - link_coords: list[str], - bp_mask: DataArray | None, + inputs: _PwlInputs, method: str, - sign: str, active: LinearExpression | None = None, ) -> str: - """ - Dispatch continuous piecewise constraints. - - Returns the resolved method name ("lp", "sos2", or "incremental"). - """ - link_dim = "_pwl_var" - - if _try_lp(model, name, lin_exprs, bp_list, method, sign, active): + """Returns the resolved method name (``"lp"``, ``"sos2"``, ``"incremental"``).""" + if _try_lp(model, name, inputs, method, active): return "lp" - links = _build_links( - model, lin_exprs, bp_list, link_coords, link_dim, sign, bp_mask - ) + links = _build_links(model, inputs) method = _resolve_sos2_vs_incremental(method, links.stacked_bp) if method == "sos2": @@ -1387,23 +1461,14 @@ def _incremental_weighted(bp: DataArray) -> LinearExpression: def _add_disjunctive( model: Model, name: str, - lin_exprs: list[LinearExpression], - bp_list: list[DataArray], - link_coords: list[str], - bp_mask: DataArray | None, - sign: str, + inputs: _PwlInputs, active: LinearExpression | None = None, ) -> None: - """ - Disjunctive SOS2 formulation. Uses the shared ``_build_links`` - split: equality on inputs (all tuples when sign='=='), signed link - on the first tuple when sign != '=='. - """ - link_dim = "_pwl_var" - links = _build_links( - model, lin_exprs, bp_list, link_coords, link_dim, sign, bp_mask - ) + """Disjunctive SOS2 formulation.""" + link_dim = inputs.link_dim + links = _build_links(model, inputs) stacked_bp = links.stacked_bp + bp_mask = inputs.bp_mask _validate_numeric_breakpoint_coords(stacked_bp) if not _has_trailing_nan_only(stacked_bp): diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 7224a94a..0e2b8305 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -616,9 +616,8 @@ def test_sign_le_respected_by_solver(self) -> None: y = m.add_variables(lower=0, upper=40, name="y") # Two segments forming a concave profile: (0,0)→(10,20), (10,20)→(20,30) m.add_piecewise_formulation( - (y, segments([[0.0, 20.0], [20.0, 30.0]])), + (y, segments([[0.0, 20.0], [20.0, 30.0]]), "<="), (x, segments([[0.0, 10.0], [10.0, 20.0]])), - sign="<=", ) m.add_constraints(x == 15) m.add_objective(-y) # maximise y @@ -656,9 +655,8 @@ def test_sign_le_hits_correct_segment( x = m.add_variables(lower=0, upper=30, name="x") y = m.add_variables(lower=0, upper=50, name="y") m.add_piecewise_formulation( - (y, segments([[0.0, 10.0], [20.0, 35.0]])), # two slopes: 2 and 1.5 + (y, segments([[0.0, 10.0], [20.0, 35.0]]), "<="), # two slopes: 2 and 1.5 (x, segments([[0.0, 5.0], [15.0, 25.0]])), - sign="<=", ) m.add_constraints(x == x_fix) m.add_objective(-y) @@ -672,9 +670,8 @@ def test_sign_le_in_forbidden_zone_infeasible(self) -> None: x = m.add_variables(lower=0, upper=30, name="x") y = m.add_variables(lower=0, upper=50, name="y") m.add_piecewise_formulation( - (y, segments([[0.0, 10.0], [20.0, 35.0]])), + (y, segments([[0.0, 10.0], [20.0, 35.0]]), "<="), (x, segments([[0.0, 5.0], [15.0, 25.0]])), - sign="<=", ) m.add_constraints(x == 10.0) # in the gap (5, 15) m.add_objective(-y) @@ -1463,7 +1460,7 @@ def test_scalar_coord_dropped(self) -> None: class TestSignParameter: - """Tests for sign="<=" / ">=" with the first-tuple convention.""" + """Tests for per-tuple sign on add_piecewise_formulation.""" def test_default_is_equality(self) -> None: m = Model() @@ -1473,12 +1470,50 @@ def test_default_is_equality(self) -> None: # no output_link for equality — single stacked link only assert f"pwl0{PWL_OUTPUT_LINK_SUFFIX}" not in m.constraints - def test_invalid_sign_raises(self) -> None: + def test_invalid_per_tuple_sign_raises(self) -> None: m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") with pytest.raises(ValueError, match="sign must be"): - m.add_piecewise_formulation((x, [0, 10]), (y, [0, 5]), sign="!") # type: ignore + m.add_piecewise_formulation((x, [0, 10], "!"), (y, [0, 5])) # type: ignore + + def test_old_sign_kwarg_raises_with_migration_help(self) -> None: + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + with pytest.raises(TypeError, match="sign=.*has been removed"): + m.add_piecewise_formulation((x, [0, 10]), (y, [0, 5]), sign="<=") # type: ignore[call-arg] + + def test_two_bounded_tuples_raises(self) -> None: + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + with pytest.raises(ValueError, match="At most one tuple"): + m.add_piecewise_formulation((x, [0, 10], "<="), (y, [0, 5], ">=")) + + def test_three_tuples_with_inequality_raises(self) -> None: + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + z = m.add_variables(name="z") + with pytest.raises(ValueError, match="3\\+ tuples"): + m.add_piecewise_formulation( + (x, [0, 10], "<="), + (y, [0, 5]), + (z, [0, 1]), + ) + + def test_bounded_tuple_in_second_position(self) -> None: + """User's tuple order is preserved — bounded tuple need not be first.""" + m = Model() + x = m.add_variables(lower=0, upper=30, name="x") + y = m.add_variables(lower=0, upper=40, name="y") + f = m.add_piecewise_formulation( + (x, [0, 10, 20, 30]), + (y, [0, 20, 30, 35], "<="), + ) + # LP fast-path still triggers regardless of tuple position + assert f.method == "lp" def test_lp_with_equality_raises(self) -> None: m = Model() @@ -1494,9 +1529,8 @@ def test_auto_picks_lp_for_concave_le(self) -> None: fuel = m.add_variables(lower=0, upper=40, name="fuel") # Concave: slopes 2, 1, 0.5 m.add_piecewise_formulation( - (fuel, [0, 20, 30, 35]), + (fuel, [0, 20, 30, 35], "<="), (power, [0, 10, 20, 30]), - sign="<=", ) assert f"pwl0{PWL_CHORD_SUFFIX}" in m.constraints assert f"pwl0{PWL_DOMAIN_LO_SUFFIX}" in m.constraints @@ -1511,9 +1545,8 @@ def test_auto_picks_lp_for_convex_ge(self) -> None: y = m.add_variables(lower=0, upper=100, name="y") # Convex: slopes 1, 2, 3 m.add_piecewise_formulation( - (y, [0, 10, 30, 60]), + (y, [0, 10, 30, 60], ">="), (x, [0, 10, 20, 30]), - sign=">=", ) assert f"pwl0{PWL_CHORD_SUFFIX}" in m.constraints @@ -1524,9 +1557,8 @@ def test_auto_falls_back_to_sos2_for_nonmonotonic(self) -> None: y = m.add_variables(name="y") # Non-monotonic x m.add_piecewise_formulation( - (y, [0, 5, 2, 20]), + (y, [0, 5, 2, 20], "<="), (x, [0, 10, 5, 50]), - sign="<=", ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables assert f"pwl0{PWL_OUTPUT_LINK_SUFFIX}" in m.constraints @@ -1537,9 +1569,8 @@ def test_auto_concave_ge_falls_back_from_lp(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") f = m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), # concave + (y, [0, 20, 30, 35], ">="), # concave (x, [0, 10, 20, 30]), - sign=">=", ) assert f.method != "lp" # fallback (sos2 or incremental) @@ -1549,9 +1580,8 @@ def test_auto_convex_le_falls_back_from_lp(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") f = m.add_piecewise_formulation( - (y, [0, 10, 30, 60]), # convex + (y, [0, 10, 30, 60], "<="), # convex (x, [0, 10, 20, 30]), - sign="<=", ) assert f.method != "lp" @@ -1562,9 +1592,8 @@ def test_lp_concave_ge_raises(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="convex"): m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), # concave + (y, [0, 20, 30, 35], ">="), # concave (x, [0, 10, 20, 30]), - sign=">=", method="lp", ) @@ -1576,9 +1605,8 @@ def test_lp_nonmatching_convexity_raises(self) -> None: # Convex curve, sign='<=' mismatch with pytest.raises(ValueError, match="concave"): m.add_piecewise_formulation( - (y, [0, 10, 30, 60]), # convex + (y, [0, 10, 30, 60], "<="), # convex (x, [0, 10, 20, 30]), - sign="<=", method="lp", ) @@ -1588,9 +1616,8 @@ def test_sos2_sign_le_has_output_link(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), + (y, [0, 20, 30, 35], "<="), (x, [0, 10, 20, 30]), - sign="<=", method="sos2", ) link = m.constraints[f"pwl0{PWL_OUTPUT_LINK_SUFFIX}"] @@ -1602,35 +1629,14 @@ def test_incremental_sign_le(self) -> None: x = m.add_variables(name="x") y = m.add_variables(name="y") m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), + (y, [0, 20, 30, 35], "<="), (x, [0, 10, 20, 30]), - sign="<=", method="incremental", ) assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables link = m.constraints[f"pwl0{PWL_OUTPUT_LINK_SUFFIX}"] assert (link.sign == "<=").all().item() - def test_nvar_inequality_bounds_first_tuple(self) -> None: - """N-variable: first tuple is bounded, others on curve.""" - m = Model() - fuel = m.add_variables(name="fuel") - power = m.add_variables(name="power") - heat = m.add_variables(name="heat") - m.add_piecewise_formulation( - (fuel, [0, 40, 85, 160]), # bounded - (power, [0, 30, 60, 100]), # input == - (heat, [0, 25, 55, 95]), # input == - sign="<=", - method="sos2", - ) - # inputs stacked, output signed - link = m.constraints[f"pwl0{PWL_LINK_SUFFIX}"] - output_link = m.constraints[f"pwl0{PWL_OUTPUT_LINK_SUFFIX}"] - assert "_pwl_var" in link.labels.dims # stacked inputs - assert "_pwl_var" not in output_link.labels.dims # single output - assert (output_link.sign == "<=").all().item() - def test_lp_consistency_with_sos2(self) -> None: """LP and SOS2 give the same fuel at a fixed power (within domain).""" x_pts = [0, 10, 20, 30] @@ -1643,9 +1649,8 @@ def test_lp_consistency_with_sos2(self) -> None: power = m.add_variables(lower=0, upper=30, name="power") fuel = m.add_variables(lower=0, upper=40, name="fuel") m.add_piecewise_formulation( - (fuel, y_pts), + (fuel, y_pts, "<="), (power, x_pts), - sign="<=", method=method, ) m.add_constraints(power == 15) @@ -1663,17 +1668,15 @@ def test_convexity_invariant_to_x_direction(self) -> None: xa = m_asc.add_variables(name="x") ya = m_asc.add_variables(name="y") f_asc = m_asc.add_piecewise_formulation( - (ya, [0, 20, 30, 35]), + (ya, [0, 20, 30, 35], ">="), (xa, [0, 10, 20, 30]), - sign=">=", ) m_desc = Model() xd = m_desc.add_variables(name="x") yd = m_desc.add_variables(name="y") f_desc = m_desc.add_piecewise_formulation( - (yd, [35, 30, 20, 0]), + (yd, [35, 30, 20, 0], ">="), (xd, [30, 20, 10, 0]), - sign=">=", ) assert f_asc.convexity == f_desc.convexity == "concave" # concave + >= must fall back from LP @@ -1698,9 +1701,8 @@ def test_lp_per_entity_nan_padding(self) -> None: x = m.add_variables(lower=0, upper=20, coords=[coord], name="x") y = m.add_variables(lower=0, upper=40, coords=[coord], name="y") m.add_piecewise_formulation( - (y, breakpoints(bp_y, dim="entity")), + (y, breakpoints(bp_y, dim="entity"), "<="), (x, breakpoints(bp_x, dim="entity")), - sign="<=", method=method, ) m.add_constraints(x.sel(entity="b") == 10) @@ -1721,9 +1723,8 @@ def test_lp_rejects_decreasing_x_concave_ge(self) -> None: y = m.add_variables(name="y") with pytest.raises(ValueError, match="convex"): m.add_piecewise_formulation( - (y, [35, 30, 20, 0]), # same concave curve + (y, [35, 30, 20, 0], ">="), # same concave curve (x, [30, 20, 10, 0]), # decreasing x - sign=">=", method="lp", ) @@ -1742,9 +1743,8 @@ def test_active_off_with_sign_le_leaves_lower_open(self, method: Method) -> None y = m.add_variables(lower=-100, upper=100, name="y") active = m.add_variables(binary=True, name="active") m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), + (y, [0, 20, 30, 35], "<="), (x, [0, 10, 20, 30]), - sign="<=", method=method, active=active, ) @@ -1769,9 +1769,8 @@ def test_active_off_with_sign_le_and_lower_zero_pins_output(self) -> None: y = m.add_variables(lower=0, upper=100, name="y") # the recipe active = m.add_variables(binary=True, name="active") m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), + (y, [0, 20, 30, 35], "<="), (x, [0, 10, 20, 30]), - sign="<=", method="sos2", active=active, ) @@ -1788,9 +1787,8 @@ def test_active_off_with_sign_le_disjunctive(self) -> None: y = m.add_variables(lower=-100, upper=100, name="y") active = m.add_variables(binary=True, name="active") m.add_piecewise_formulation( - (y, segments([[0.0, 20.0], [20.0, 35.0]])), + (y, segments([[0.0, 20.0], [20.0, 35.0]]), "<="), (x, segments([[0.0, 10.0], [10.0, 30.0]])), - sign="<=", active=active, ) m.add_constraints(active == 0) @@ -1810,9 +1808,8 @@ def test_lp_active_explicit_raises(self) -> None: u = m.add_variables(binary=True, name="u") with pytest.raises(ValueError, match="active"): m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), + (y, [0, 20, 30, 35], "<="), (x, [0, 10, 20, 30]), - sign="<=", method="lp", active=u, ) @@ -1828,9 +1825,8 @@ def test_lp_accepts_linear_curve(self) -> None: x = m.add_variables(lower=0, upper=30, name="x") y = m.add_variables(lower=0, upper=60, name="y") f = m.add_piecewise_formulation( - (y, [0, 10, 20, 30]), # linear (all slopes = 1) + (y, [0, 10, 20, 30], sign), # linear (all slopes = 1) (x, [0, 10, 20, 30]), - sign=sign, method="lp", ) assert f.method == "lp" @@ -1848,9 +1844,8 @@ def test_auto_logs_when_lp_is_skipped( y = m.add_variables(name="y") with caplog.at_level(logging.INFO, logger="linopy.piecewise"): m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), # concave + sign='>=' → LP skipped + (y, [0, 20, 30, 35], ">="), # concave + sign='>=' → LP skipped (x, [0, 10, 20, 30]), - sign=">=", ) assert "LP not applicable" in caplog.text @@ -1864,9 +1859,8 @@ def test_lp_domain_bound_infeasible_when_x_out_of_range(self) -> None: x = m.add_variables(lower=0, upper=100, name="x") y = m.add_variables(lower=0, upper=100, name="y") m.add_piecewise_formulation( - (y, [0, 20, 30, 35]), + (y, [0, 20, 30, 35], "<="), (x, [0, 10, 20, 30]), # x_max = 30 - sign="<=", method="lp", ) m.add_constraints(x >= 50) @@ -1890,9 +1884,8 @@ def test_lp_matches_sos2_on_multi_dim_variables(self) -> None: x = m.add_variables(lower=0, upper=30, coords=[entities], name="x") y = m.add_variables(lower=0, upper=40, coords=[entities], name="y") m.add_piecewise_formulation( - (y, breakpoints(bp_y, dim="entity")), + (y, breakpoints(bp_y, dim="entity"), "<="), (x, breakpoints(bp_x, dim="entity")), - sign="<=", method=method, ) m.add_constraints(x.sel(entity="a") == 15) @@ -1920,9 +1913,7 @@ def test_lp_consistency_with_sos2_both_directions(self) -> None: m = Model() p = m.add_variables(lower=0, upper=30, name="p") f = m.add_variables(lower=0, upper=50, name="f") - m.add_piecewise_formulation( - (f, y_pts), (p, x_pts), sign="<=", method=method - ) + m.add_piecewise_formulation((f, y_pts, "<="), (p, x_pts), method=method) m.add_constraints(p == 15) m.add_objective(obj_sign * f) m.solve() diff --git a/test/test_piecewise_feasibility.py b/test/test_piecewise_feasibility.py index cedf6d40..ed5dd49b 100644 --- a/test/test_piecewise_feasibility.py +++ b/test/test_piecewise_feasibility.py @@ -37,7 +37,6 @@ Sign: TypeAlias = Literal["<=", ">="] Method: TypeAlias = Literal["lp", "sos2", "incremental"] -MethodND: TypeAlias = Literal["sos2", "incremental"] # LP doesn't support N > 2 TOL = 1e-5 X_LO, X_HI = -100.0, 100.0 @@ -113,9 +112,8 @@ def build_model(curve: Curve, method: Method) -> tuple[Model, Variable, Variable x = m.add_variables(lower=X_LO, upper=X_HI, name="x") y = m.add_variables(lower=Y_LO, upper=Y_HI, name="y") m.add_piecewise_formulation( - (y, list(curve.y_pts)), + (y, list(curve.y_pts), curve.sign), (x, list(curve.x_pts)), - sign=curve.sign, method=method, ) return m, x, y @@ -268,194 +266,6 @@ def test_just_past_curve(self, curve: Curve, method: Method) -> None: ) -# --------------------------------------------------------------------------- -# 3-variable inequality: sign='<=' splits bounded output from equality inputs -# --------------------------------------------------------------------------- - - -class TestNVariableInequality: - """ - 3-variable ``sign="<="``: the first tuple (output) is bounded above, - the remaining tuples (inputs) are pinned on the curve — equality-linked. - - LP does not support ``N > 2``, so this is SOS2 vs incremental only. - The feasible region is a "ribbon" along the fuel axis parameterised - by the curve's ``(power, heat)`` trajectory: - - { (fuel, power, heat) : ∃ λ SOS2 with Σλ=1, - power = Σλ·p_i, heat = Σλ·h_i, FUEL_LO ≤ fuel ≤ Σλ·f_i } - - Tests probe this region from several angles: a vertex-enumeration - oracle for rotated objectives, plus targeted feasible/infeasible - point checks. - """ - - BP = { - "power": (0, 30, 60, 100), - "fuel": (0, 40, 85, 160), # bounded output (first tuple) - "heat": (0, 25, 55, 95), # input, forced to equality - } - FUEL_LO, FUEL_HI = 0.0, 200.0 - POWER_LO, POWER_HI = 0.0, 100.0 - HEAT_LO, HEAT_HI = 0.0, 100.0 - - @pytest.fixture(params=["sos2", "incremental"]) - def method_3var(self, request: pytest.FixtureRequest) -> MethodND: - return request.param - - # ---- helpers -------------------------------------------------------- - - def _build(self, method: MethodND) -> tuple[Model, Variable, Variable, Variable]: - """CHP model with sign='<=': fuel bounded, power/heat equality-linked.""" - m = Model() - power = m.add_variables(lower=self.POWER_LO, upper=self.POWER_HI, name="power") - fuel = m.add_variables(lower=self.FUEL_LO, upper=self.FUEL_HI, name="fuel") - heat = m.add_variables(lower=self.HEAT_LO, upper=self.HEAT_HI, name="heat") - m.add_piecewise_formulation( - (fuel, list(self.BP["fuel"])), - (power, list(self.BP["power"])), - (heat, list(self.BP["heat"])), - sign="<=", - method=method, - ) - return m, fuel, power, heat - - def _oracle_support_3d( - self, alpha_f: float, alpha_p: float, alpha_h: float - ) -> float: - """ - Ground-truth ``min α_f·fuel + α_p·power + α_h·heat`` over the region. - - The region is a convex polytope with vertices at each breakpoint - in two "layers": the top ``(f_i, p_i, h_i)`` and the bottom - ``(FUEL_LO, p_i, h_i)`` — linear objective extrema are at vertices. - """ - fuels = self.BP["fuel"] - powers = self.BP["power"] - heats = self.BP["heat"] - top = [ - alpha_f * f + alpha_p * p + alpha_h * h - for f, p, h in zip(fuels, powers, heats) - ] - bot = [ - alpha_f * self.FUEL_LO + alpha_p * p + alpha_h * h - for p, h in zip(powers, heats) - ] - return min(top + bot) - - # ---- existing test: fuel pushed against its upper bound ------------- - - @pytest.mark.parametrize("power_fix", [0, 15, 30, 45, 60, 80, 100]) - def test_first_tuple_bounded_rest_equal( - self, method_3var: MethodND, power_fix: float - ) -> None: - m, fuel, power, heat = self._build(method_3var) - m.add_constraints(power == power_fix) - m.add_objective(-fuel) # push fuel against its bound - status, _ = m.solve() - assert status == "ok" - - expect_fuel = float(np.interp(power_fix, self.BP["power"], self.BP["fuel"])) - expect_heat = float(np.interp(power_fix, self.BP["power"], self.BP["heat"])) - - assert abs(float(m.solution["fuel"]) - expect_fuel) < TOL, ( - f"{method_3var}: fuel at power={power_fix} should hit " - f"f(x)={expect_fuel}, got {float(m.solution['fuel'])}" - ) - assert abs(float(m.solution["heat"]) - expect_heat) < TOL, ( - f"{method_3var}: heat at power={power_fix} must equal " - f"f(x)={expect_heat}, got {float(m.solution['heat'])}" - ) - - # ---- new: heat drifting off the curve is infeasible ----------------- - - @pytest.mark.parametrize("power_fix", [15, 45, 80]) - def test_heat_off_curve_is_infeasible( - self, method_3var: MethodND, power_fix: float - ) -> None: - """ - Heat is equality-linked. Pinning heat away from ``f_heat(power)`` - must make the model infeasible under both methods. - """ - expect_heat = float(np.interp(power_fix, self.BP["power"], self.BP["heat"])) - m, fuel, power, heat = self._build(method_3var) - m.add_constraints(power == power_fix) - m.add_constraints(heat == expect_heat + 5.0) # nudge off the curve - m.add_objective(fuel) - status, _ = m.solve() - assert status != "ok", ( - f"{method_3var}: heat={expect_heat + 5} at power={power_fix} " - f"should be infeasible (curve has heat={expect_heat})" - ) - - # ---- new: interior point is feasible -------------------------------- - - @pytest.mark.parametrize("power_fix", [15, 45, 80]) - def test_interior_point_is_feasible( - self, method_3var: MethodND, power_fix: float - ) -> None: - """ - With power/heat on the curve and fuel well below its upper - bound, the point is interior to the ribbon — must be feasible. - """ - expect_heat = float(np.interp(power_fix, self.BP["power"], self.BP["heat"])) - expect_fuel = float(np.interp(power_fix, self.BP["power"], self.BP["fuel"])) - m, fuel, power, heat = self._build(method_3var) - m.add_constraints(power == power_fix) - m.add_constraints(heat == expect_heat) - m.add_constraints(fuel == expect_fuel - 10.0) # below the bound - m.add_objective(fuel) - status, _ = m.solve() - assert status == "ok", ( - f"{method_3var}: interior point (power={power_fix}, " - f"heat={expect_heat}, fuel={expect_fuel - 10}) should be feasible" - ) - - # ---- new: rotated objective in 3D ----------------------------------- - - DIRECTIONS_3D = [ - pytest.param(-1.0, 0.0, 0.0, id="maxfuel"), - pytest.param(+1.0, 0.0, 0.0, id="minfuel"), - pytest.param(0.0, -1.0, 0.0, id="maxpower"), - pytest.param(0.0, +1.0, 0.0, id="minpower"), - pytest.param(0.0, 0.0, -1.0, id="maxheat"), - pytest.param(0.0, 0.0, +1.0, id="minheat"), - pytest.param(-1.0, -1.0, -1.0, id="maxall"), - pytest.param(+1.0, +1.0, +1.0, id="minall"), - ] - - @pytest.mark.parametrize("alpha_f, alpha_p, alpha_h", DIRECTIONS_3D) - def test_rotated_support_matches_oracle( - self, - method_3var: MethodND, - alpha_f: float, - alpha_p: float, - alpha_h: float, - ) -> None: - """ - Support function equivalence in 3-space: each method lands at - the same vertex as the vertex-enumeration oracle. - """ - m, fuel, power, heat = self._build(method_3var) - m.add_objective(alpha_f * fuel + alpha_p * power + alpha_h * heat) - status, _ = m.solve() - assert status == "ok", ( - f"{method_3var}: solve failed at ({alpha_f},{alpha_p},{alpha_h})" - ) - fs = float(m.solution["fuel"]) - ps = float(m.solution["power"]) - hs = float(m.solution["heat"]) - got = alpha_f * fs + alpha_p * ps + alpha_h * hs - want = self._oracle_support_3d(alpha_f, alpha_p, alpha_h) - assert abs(got - want) < TOL, ( - f"\n method: {method_3var}" - f"\n direction: (α_fuel={alpha_f:+}, α_power={alpha_p:+}, α_heat={alpha_h:+})" - f"\n attained: fuel={fs:+.6f}, power={ps:+.6f}, heat={hs:+.6f}" - f"\n attained obj: {got:+.6f} oracle obj: {want:+.6f}" - f"\n diff: {got - want:+.3e} (TOL={TOL:.1e})" - ) - - # --------------------------------------------------------------------------- # Hand-computed anchors — sanity-check the oracle itself # --------------------------------------------------------------------------- @@ -540,48 +350,3 @@ def test_convex_ge_at_midsegment(self, method: Method) -> None: m.add_objective(y) # minimise — pushes y against the lower bound (curve) m.solve() assert float(m.solution["y"]) == pytest.approx(2.5, abs=TOL) - - # ---- 3-variable CHP ------------------------------------------------ - - @pytest.mark.parametrize("method_3var", ["sos2", "incremental"]) - def test_chp_at_breakpoint(self, method_3var: MethodND) -> None: - """CHP at power=60 (exact breakpoint 2): max fuel=85, heat=55.""" - m = Model() - power = m.add_variables(lower=0, upper=100, name="power") - fuel = m.add_variables(lower=0, upper=200, name="fuel") - heat = m.add_variables(lower=0, upper=100, name="heat") - m.add_piecewise_formulation( - (fuel, [0, 40, 85, 160]), - (power, [0, 30, 60, 100]), - (heat, [0, 25, 55, 95]), - sign="<=", - method=method_3var, - ) - m.add_constraints(power == 60.0) - m.add_objective(-fuel) - m.solve() - assert float(m.solution["fuel"]) == pytest.approx(85.0, abs=TOL) - assert float(m.solution["heat"]) == pytest.approx(55.0, abs=TOL) - - @pytest.mark.parametrize("method_3var", ["sos2", "incremental"]) - def test_chp_at_midsegment(self, method_3var: MethodND) -> None: - """ - CHP at power=45 (midway between bp1=30 and bp2=60): - fuel = (40 + 85)/2 = 62.5, heat = (25 + 55)/2 = 40.0. - """ - m = Model() - power = m.add_variables(lower=0, upper=100, name="power") - fuel = m.add_variables(lower=0, upper=200, name="fuel") - heat = m.add_variables(lower=0, upper=100, name="heat") - m.add_piecewise_formulation( - (fuel, [0, 40, 85, 160]), - (power, [0, 30, 60, 100]), - (heat, [0, 25, 55, 95]), - sign="<=", - method=method_3var, - ) - m.add_constraints(power == 45.0) - m.add_objective(-fuel) - m.solve() - assert float(m.solution["fuel"]) == pytest.approx(62.5, abs=TOL) - assert float(m.solution["heat"]) == pytest.approx(40.0, abs=TOL) From 0d3ad0115ae432037c66c066a98f85b5426e6281 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 16:41:31 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- doc/piecewise-linear-constraints.rst | 14 ++++++-------- linopy/piecewise.py | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index a1331dba..32786eea 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -40,7 +40,7 @@ Quick Start # the requested sign; SOS2/incremental otherwise). m.add_piecewise_formulation( (fuel, [0, 20, 30, 35], "<="), # bounded by the curve - (power, [0, 10, 20, 30]), # pinned to the curve + (power, [0, 10, 20, 30]), # pinned to the curve ) Each ``(expression, breakpoints[, sign])`` tuple pairs a variable with its @@ -59,12 +59,12 @@ API .. code-block:: python m.add_piecewise_formulation( - (expr1, breakpoints1), # pinned (sign defaults to "==") - (expr2, breakpoints2, "<="), # or with an explicit sign + (expr1, breakpoints1), # pinned (sign defaults to "==") + (expr2, breakpoints2, "<="), # or with an explicit sign ..., method="auto", # "auto", "sos2", "incremental", or "lp" - active=None, # binary variable to gate the constraint - name=None, # base name for generated variables/constraints + active=None, # binary variable to gate the constraint + name=None, # base name for generated variables/constraints ) Creates auxiliary variables and constraints that enforce either a joint @@ -106,9 +106,7 @@ stays 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) - ) + m.add_piecewise_formulation((power, p_pts), (fuel, f_pts), (heat, h_pts)) **Restrictions:** diff --git a/linopy/piecewise.py b/linopy/piecewise.py index fba8f4fa..0903f0a9 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -780,7 +780,7 @@ def add_piecewise_formulation( # fuel <= f(power) — concave curve, bounded above m.add_piecewise_formulation( - (fuel, y_pts, "<="), + (fuel, y_pts, "<="), (power, x_pts), ) From aecde0d7df4220eb7fe0d823d6073f8b5676e714 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:10:47 +0200 Subject: [PATCH 3/4] docs(piecewise): leverage per-tuple notation, rephrase restrictions as invitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops vestigial framing from the old API throughout the user docs and example notebooks. The "first-tuple convention" and "N−1 jointly pinned" scaffolding existed only to explain why position 0 was special — with per-tuple sign that explanation isn't needed. Each tuple's role is now visible at the call site. Restrictions (one bounded tuple max; 3+ must be all-equality) are reframed as invitations: "open an issue at https://github.com/PyPSA/linopy/issues if you have a use case." We don't actually know what shape future support takes — better to invite scoping than to commit to a specific "future bivariate / triangulated piecewise API" we haven't designed. - doc/piecewise-linear-constraints.rst: rewrite the restrictions block, the N-variable linking section, and the SOS2 generated-names list to use the new framing. Update See Also link target. - examples/piecewise-inequality-bounds.ipynb: rewrite intro, math, code, and summary cells. Drop the four cells (10–13) that were dedicated to the now-rejected 3-variable inequality case (the 3D ribbon plot and its "first-tuple convention" justification). Notebook executes end-to-end on Gurobi. - examples/piecewise-linear-constraints.ipynb: drop the 3-variable CHP inequality demo (cells 12–13); the joint-equality CHP case is already in section 6. Update the inequality intro for per-tuple sign. - linopy/piecewise.py: rephrase docstring restrictions and the entry-point ValueError to invite an issue rather than promise a specific future API. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/piecewise-linear-constraints.rst | 27 +- examples/piecewise-inequality-bounds.ipynb | 383 +------------------- examples/piecewise-linear-constraints.ipynb | 78 +--- linopy/piecewise.py | 13 +- 4 files changed, 36 insertions(+), 465 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 32786eea..17bb7b95 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -108,12 +108,14 @@ stays pinned. # 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:** +**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 ``"=="``. The multi-input - bounded case is reserved for a future bivariate / triangulated piecewise - API. +- 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.** For 2 variables with ``sign="<="`` on a concave curve :math:`f`, the feasible region is the **hypograph** of :math:`f` on its @@ -274,8 +276,9 @@ 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 — see the per-tuple sign -section above. +more tuples, only ``"=="`` signs are accepted — bounding one expression +by a multi-input curve isn't supported yet; see the per-tuple sign +section above for the issue link. Formulation Methods @@ -539,10 +542,10 @@ each formulation creates a predictable set of names: - ``{N}_lambda`` — variable, interpolation weights - ``{N}_convex`` — constraint, ``sum(lambda) == 1`` (or ``== active``) -- ``{N}_link`` — constraint, equality link (stacked inputs when - ``sign != "=="``, all tuples when ``sign="=="``) -- ``{N}_output_link`` — constraint, signed link on the first tuple - *(only when* ``sign != "=="`` *)* +- ``{N}_link`` — constraint, equality link (pinned tuples when one tuple + is bounded; all tuples when all are equality) +- ``{N}_output_link`` — constraint, signed link on the bounded tuple + *(only when one tuple carries* ``"<="`` */* ``">="`` *)* **Incremental** (``method="incremental"``): @@ -575,6 +578,6 @@ See Also - :doc:`piecewise-linear-constraints-tutorial` — worked examples of the equality API (notebook) -- :doc:`piecewise-inequality-bounds-tutorial` — the ``sign`` parameter, the LP - formulation and the first-tuple convention (notebook) +- :doc:`piecewise-inequality-bounds-tutorial` — per-tuple sign and the LP + formulation (notebook) - :doc:`sos-constraints` — low-level SOS1/SOS2 constraint API diff --git a/examples/piecewise-inequality-bounds.ipynb b/examples/piecewise-inequality-bounds.ipynb index e6684b62..2fa929ed 100644 --- a/examples/piecewise-inequality-bounds.ipynb +++ b/examples/piecewise-inequality-bounds.ipynb @@ -3,35 +3,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "# Piecewise inequalities — the `sign` parameter\n", - "\n", - "`add_piecewise_formulation` accepts a ``sign`` parameter to express one-sided\n", - "bounds of the form `y ≤ f(x)` or `y ≥ f(x)`:\n", - "\n", - "```python\n", - "m.add_piecewise_formulation(\n", - " (fuel, y_pts), # output — gets the sign\n", - " (power, x_pts), # input — always equality\n", - " sign=\"<=\",\n", - ")\n", - "```\n", - "\n", - "This notebook walks through the math, the **first-tuple convention**, and\n", - "the feasible regions produced by each method (LP, SOS2, incremental).\n", - "\n", - "## Key points\n", - "\n", - "| | Behaviour |\n", - "|---|---|\n", - "| `sign=\"==\"` (default) | All expressions lie exactly on the curve. |\n", - "| `sign=\"<=\"` | First expression is bounded above by `f(rest)`. |\n", - "| `sign=\">=\"` | First expression is bounded below by `f(rest)`. |\n", - "\n", - "**First-tuple convention**: only the *first* tuple's variable gets the sign.\n", - "All remaining tuples are equality (inputs on the curve). This restriction\n", - "keeps the semantics unambiguous — it's always \"output sign function(inputs)\"." - ] + "source": "# Piecewise inequalities — per-tuple sign\n\n`add_piecewise_formulation` accepts an optional third tuple element, `\"<=\"` or `\">=\"`, that marks one expression as **bounded** by the piecewise curve instead of pinned to it:\n\n```python\nm.add_piecewise_formulation(\n (fuel, y_pts, \"<=\"), # bounded above by the curve\n (power, x_pts), # pinned to the curve\n)\n```\n\nThis notebook walks through the math, the curvature × sign matching that lets `method=\"auto\"` skip MIP machinery entirely, and the feasible regions produced by each method (LP, SOS2, incremental).\n\n## Key points\n\n| Tuple form | Behaviour |\n|---|---|\n| `(expr, breaks)` | Pinned: `expr` lies exactly on the curve. |\n| `(expr, breaks, \"<=\")` | Bounded above: `expr ≤ f(other tuples)`. |\n| `(expr, breaks, \">=\")` | Bounded below: `expr ≥ f(other tuples)`. |\n\nCurrently at most one tuple may carry a non-equality sign, and 3+ tuples must all be equality. Multi-bounded and N≥3 inequality cases aren't supported yet — if you have a concrete use case, please open an issue at https://github.com/PyPSA/linopy/issues so we can scope it properly." }, { "cell_type": "code", @@ -53,64 +25,12 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Mathematical formulation\n", - "\n", - "### Equality (`sign=\"==\"`)\n", - "\n", - "For $N$ expressions $e_1, \\dots, e_N$ with breakpoints $B_{j,0}, \\dots, B_{j,n}$ per expression $j$, the SOS2 formulation introduces interpolation weights $\\lambda_i \\in [0,1]$:\n", - "\n", - "$$\n", - "\\sum_{i=0}^{n} \\lambda_i = 1, \\qquad \\text{SOS2}(\\lambda_0, \\dots, \\lambda_n),\n", - "\\qquad e_j = \\sum_{i=0}^{n} \\lambda_i \\, B_{j,i} \\ \\ \\forall j.\n", - "$$\n", - "\n", - "Every expression is tied to the same $\\lambda$ — they share a single point on the curve.\n", - "\n", - "### Inequality (`sign=\"<=\"` or `\">=\"`, first-tuple convention)\n", - "\n", - "The *first* expression $e_1$ is the output; the rest are inputs forced to equality:\n", - "\n", - "$$\n", - "\\sum_{i=0}^{n} \\lambda_i = 1, \\qquad \\text{SOS2}(\\lambda),\n", - "\\qquad e_j = \\sum_{i=0}^{n} \\lambda_i \\, B_{j,i}\\ \\ \\forall j \\ge 2,\n", - "\\qquad e_1 \\ \\text{sign}\\ \\sum_{i=0}^{n} \\lambda_i \\, B_{1,i}.\n", - "$$\n", - "\n", - "Inputs $e_2, \\dots, e_N$ are pinned to the curve at a shared $\\lambda$; the output $e_1$ is then bounded (above or below) by the interpolated value. The internal split is visible in the generated constraints: a single stacked `*_link` for inputs and a separate `*_output_link` carrying the sign.\n", - "\n", - "### LP method (2-variable inequality, convex/concave curve)\n", - "\n", - "For $y \\le f(x)$ on a concave $f$ (or $y \\ge f(x)$ on convex), we add one tangent (chord) per segment $k$:\n", - "\n", - "$$\n", - "y \\le m_k \\cdot x + c_k \\ \\ \\forall k,\n", - "\\qquad x_0 \\le x \\le x_n,\n", - "$$\n", - "\n", - "where $m_k = (y_{k+1}-y_k)/(x_{k+1}-x_k)$ and $c_k = y_k - m_k x_k$. The intersection of all chord inequalities equals the hypograph within the x-domain. No auxiliary variables are created.\n", - "\n", - "### Incremental (delta) formulation\n", - "\n", - "An MIP alternative to SOS2 for strictly monotonic breakpoints, using fill fractions $\\delta_i \\in [0,1]$ and binaries $z_i$ per segment:\n", - "\n", - "$$\n", - "\\delta_{i+1} \\le \\delta_i, \\quad z_{i+1} \\le \\delta_i, \\quad \\delta_i \\le z_i,\n", - "\\qquad e_j = B_{j,0} + \\sum_i \\delta_i\\,(B_{j,i+1}-B_{j,i}).\n", - "$$\n", - "\n", - "Same sign split as SOS2: inputs use equality, output uses the requested sign." - ] + "source": "## Mathematical formulation\n\n### All-equality (every tuple pinned)\n\nFor $N$ expressions $e_1, \\dots, e_N$ with breakpoints $B_{j,0}, \\dots, B_{j,n}$ per expression $j$, the SOS2 formulation introduces interpolation weights $\\lambda_i \\in [0,1]$:\n\n$$\n\\sum_{i=0}^{n} \\lambda_i = 1, \\qquad \\text{SOS2}(\\lambda_0, \\dots, \\lambda_n),\n\\qquad e_j = \\sum_{i=0}^{n} \\lambda_i \\, B_{j,i} \\ \\ \\forall j.\n$$\n\nEvery expression is tied to the same $\\lambda$ — they share a single point on the curve.\n\n### One bounded tuple\n\nWhen tuple $b$ carries a non-equality sign, its link becomes one-sided; the pinned tuples keep the equality:\n\n$$\n\\sum_{i=0}^{n} \\lambda_i = 1, \\qquad \\text{SOS2}(\\lambda),\n\\qquad e_j = \\sum_{i=0}^{n} \\lambda_i \\, B_{j,i}\\ \\ \\forall j \\ne b,\n\\qquad e_b \\ \\text{sign}\\ \\sum_{i=0}^{n} \\lambda_i \\, B_{b,i}.\n$$\n\nThe pinned expressions are tied to a shared $\\lambda$; the bounded one is then bounded (above or below) by the interpolated value. The split is visible in the generated constraints: a single stacked `*_link` for pinned tuples and a separate `*_output_link` carrying the sign.\n\n### LP method (2-variable inequality, convex/concave curve)\n\nFor $y \\le f(x)$ on a concave $f$ (or $y \\ge f(x)$ on convex), we add one tangent (chord) per segment $k$:\n\n$$\ny \\le m_k \\cdot x + c_k \\ \\ \\forall k,\n\\qquad x_0 \\le x \\le x_n,\n$$\n\nwhere $m_k = (y_{k+1}-y_k)/(x_{k+1}-x_k)$ and $c_k = y_k - m_k x_k$. The intersection of all chord inequalities equals the hypograph within the x-domain. No auxiliary variables are created.\n\n### Incremental (delta) formulation\n\nAn MIP alternative to SOS2 for strictly monotonic breakpoints, using fill fractions $\\delta_i \\in [0,1]$ and binaries $z_i$ per segment:\n\n$$\n\\delta_{i+1} \\le \\delta_i, \\quad z_{i+1} \\le \\delta_i, \\quad \\delta_i \\le z_i,\n\\qquad e_j = B_{j,0} + \\sum_i \\delta_i\\,(B_{j,i+1}-B_{j,i}).\n$$\n\nSame split as SOS2: pinned tuples use equality; the bounded one uses the requested sign." }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Setup — a concave curve\n", - "\n", - "We use a concave, monotonically increasing curve. With `sign=\"<=\"`, the LP\n", - "method is applicable (concave + `<=` is a tight relaxation)." - ] + "source": "## Setup — a concave curve\n\nWe use a concave, monotonically increasing curve. With a tuple bounded `<=`, the LP method is applicable (concave + `<=` is a tight relaxation)." }, { "cell_type": "code", @@ -136,23 +56,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Three methods, identical feasible region\n", - "\n", - "With `sign=\"<=\"` and our concave curve, the three methods give the **same**\n", - "feasible region within `[x_0, x_n]`:\n", - "\n", - "- **`method=\"lp\"`** — tangent lines + domain bounds. No auxiliary variables.\n", - "- **`method=\"sos2\"`** — lambdas + SOS2 + split link (input equality, output\n", - " signed). Solver picks the segment.\n", - "- **`method=\"incremental\"`** — delta fractions + binaries + split link.\n", - " Same mathematics, MIP encoding instead of SOS2.\n", - "\n", - "`method=\"auto\"` dispatches to `\"lp\"` whenever applicable — it's always\n", - "preferable because it's pure LP.\n", - "\n", - "Let's verify they produce the same solution at `power=15`." - ] + "source": "## Three methods, identical feasible region\n\nWith one tuple bounded `<=` and our concave curve, the three methods give the **same** feasible region within `[x_0, x_n]`:\n\n- **`method=\"lp\"`** — tangent lines + domain bounds. No auxiliary variables.\n- **`method=\"sos2\"`** — lambdas + SOS2 + split link (pinned equality, bounded signed). Solver picks the segment.\n- **`method=\"incremental\"`** — delta fractions + binaries + split link. Same mathematics, MIP encoding instead of SOS2.\n\n`method=\"auto\"` dispatches to `\"lp\"` whenever applicable — it's always preferable because it's pure LP.\n\nLet's verify they produce the same solution at `power=15`." }, { "cell_type": "code", @@ -164,59 +68,17 @@ } }, "outputs": [], - "source": [ - "def solve(method, power_val):\n", - " m = linopy.Model()\n", - " power = m.add_variables(lower=0, upper=30, name=\"power\")\n", - " fuel = m.add_variables(lower=0, upper=40, name=\"fuel\")\n", - " m.add_piecewise_formulation(\n", - " (fuel, y_pts), # output, signed\n", - " (power, x_pts), # input, ==\n", - " sign=\"<=\",\n", - " method=method,\n", - " )\n", - " m.add_constraints(power == power_val)\n", - " m.add_objective(-fuel) # maximise fuel to push against the bound\n", - " m.solve()\n", - " return float(m.solution[\"fuel\"]), list(m.variables), list(m.constraints)\n", - "\n", - "\n", - "for method in [\"lp\", \"sos2\", \"incremental\"]:\n", - " fuel_val, vars_, cons_ = solve(method, 15)\n", - " print(f\"{method:12}: fuel={fuel_val:.2f} vars={vars_} cons={cons_}\")" - ] + "source": "def solve(method, power_val):\n m = linopy.Model()\n power = m.add_variables(lower=0, upper=30, name=\"power\")\n fuel = m.add_variables(lower=0, upper=40, name=\"fuel\")\n m.add_piecewise_formulation(\n (fuel, y_pts, \"<=\"), # bounded\n (power, x_pts), # pinned\n method=method,\n )\n m.add_constraints(power == power_val)\n m.add_objective(-fuel) # maximise fuel to push against the bound\n m.solve()\n return float(m.solution[\"fuel\"]), list(m.variables), list(m.constraints)\n\n\nfor method in [\"lp\", \"sos2\", \"incremental\"]:\n fuel_val, vars_, cons_ = solve(method, 15)\n print(f\"{method:12}: fuel={fuel_val:.2f} vars={vars_} cons={cons_}\")" }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "All three give `fuel=25` at `power=15` (which is `f(15)` exactly) — the math\n", - "is equivalent. The LP method is strictly cheaper: no auxiliary variables,\n", - "just three chord constraints and two domain bounds.\n", - "\n", - "The SOS2 and incremental methods create lambdas (or deltas + binaries) and\n", - "split the link into an input-equality constraint plus a signed output link —\n", - "but the feasible region is the same." - ] + "source": "All three give `fuel=25` at `power=15` (which is `f(15)` exactly) — the math is equivalent. The LP method is strictly cheaper: no auxiliary variables, just three chord constraints and two domain bounds.\n\nThe SOS2 and incremental methods create lambdas (or deltas + binaries) and split the link into a pinned-equality constraint plus a signed bounded link — but the feasible region is the same." }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Visualising the feasible region\n", - "\n", - "The feasible region for `(power, fuel)` with `sign=\"<=\"` is the **hypograph**\n", - "of `f` restricted to the curve's x-domain:\n", - "\n", - "$$\\{ (x, y) : x_0 \\le x \\le x_n,\\ y \\le f(x) \\}$$\n", - "\n", - "We colour green feasible points, red infeasible ones. Three test points:\n", - "\n", - "- `(15, 15)` — inside the curve, `15 ≤ f(15)=25` ✓\n", - "- `(15, 25)` — on the curve ✓\n", - "- `(15, 29)` — above `f(15)`, should be infeasible ✗\n", - "- `(35, 20)` — power beyond domain, infeasible ✗" - ] + "source": "## Visualising the feasible region\n\nThe feasible region for `(power, fuel)` with `fuel` bounded `<=` is the **hypograph** of `f` restricted to the curve's x-domain:\n\n$$\\{ (x, y) : x_0 \\le x \\le x_n,\\ y \\le f(x) \\}$$\n\nWe colour green feasible points, red infeasible ones. Three test points:\n\n- `(15, 15)` — inside the curve, `15 ≤ f(15)=25` ✓\n- `(15, 25)` — on the curve ✓\n- `(15, 29)` — above `f(15)`, should be infeasible ✗\n- `(35, 20)` — power beyond domain, infeasible ✗" }, { "cell_type": "code", @@ -262,191 +124,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "from mpl_toolkits.mplot3d.art3d import Poly3DCollection\n", - "\n", - "x_pts_3d = np.array([0.0, 30.0, 60.0, 100.0]) # power\n", - "z_pts_3d = np.array([0.0, 25.0, 55.0, 95.0]) # heat\n", - "y_pts_3d = np.array([0.0, 40.0, 85.0, 160.0]) # fuel (output)\n", - "\n", - "# Dense parameterisation of the 1-D curve\n", - "t_grid = np.linspace(0, len(x_pts_3d) - 1, 80)\n", - "power_c = np.interp(t_grid, np.arange(len(x_pts_3d)), x_pts_3d)\n", - "heat_c = np.interp(t_grid, np.arange(len(z_pts_3d)), z_pts_3d)\n", - "fuel_c = np.interp(t_grid, np.arange(len(y_pts_3d)), y_pts_3d)\n", - "\n", - "fig = plt.figure(figsize=(7, 6))\n", - "ax = fig.add_subplot(111, projection=\"3d\")\n", - "\n", - "# Shaded ribbon: (power(t), heat(t), fuel) for fuel from 0 to f(t)\n", - "polys = []\n", - "for i in range(len(t_grid) - 1):\n", - " quad = [\n", - " (power_c[i], heat_c[i], 0.0),\n", - " (power_c[i + 1], heat_c[i + 1], 0.0),\n", - " (power_c[i + 1], heat_c[i + 1], fuel_c[i + 1]),\n", - " (power_c[i], heat_c[i], fuel_c[i]),\n", - " ]\n", - " polys.append(quad)\n", - "coll = Poly3DCollection(polys, facecolor=\"lightsteelblue\", edgecolor=\"none\", alpha=0.35)\n", - "ax.add_collection3d(coll)\n", - "\n", - "# Upper boundary: the curve itself\n", - "ax.plot(power_c, heat_c, fuel_c, color=\"C0\", lw=2.5, label=\"curve f(t)\")\n", - "ax.scatter(x_pts_3d, z_pts_3d, y_pts_3d, color=\"C0\", s=50)\n", - "\n", - "# Lower boundary at fuel = 0\n", - "ax.plot(power_c, heat_c, 0 * fuel_c, color=\"gray\", lw=1, linestyle=\":\", alpha=0.7,\n", - " label=\"projection in (power, heat)\")\n", - "\n", - "ax.set(xlabel=\"power\", ylabel=\"heat\", zlabel=\"fuel\",\n", - " title=\"sign='<=' feasible region for 3 variables\")\n", - "ax.view_init(elev=20, azim=-70)\n", - "ax.legend()\n", - "plt.tight_layout()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-22T19:57:00.150636Z", - "start_time": "2026-04-22T19:57:00.012662Z" - } - }, - "outputs": [], - "source": [ - "from mpl_toolkits.mplot3d.art3d import Poly3DCollection\n", - "\n", - "x_pts_3d = np.array([0.0, 30.0, 60.0, 100.0]) # power\n", - "z_pts_3d = np.array([0.0, 25.0, 55.0, 95.0]) # heat\n", - "y_pts_3d = np.array([0.0, 40.0, 85.0, 160.0]) # fuel (output)\n", - "\n", - "# Dense parameterisation of the 1-D curve\n", - "t_grid = np.linspace(0, len(x_pts_3d) - 1, 80)\n", - "power_c = np.interp(t_grid, np.arange(len(x_pts_3d)), x_pts_3d)\n", - "heat_c = np.interp(t_grid, np.arange(len(z_pts_3d)), z_pts_3d)\n", - "fuel_c = np.interp(t_grid, np.arange(len(y_pts_3d)), y_pts_3d)\n", - "\n", - "\n", - "def draw_ribbon(ax, elev, azim, title):\n", - " # Shaded ribbon: (power(t), heat(t), fuel) for fuel from 0 to f(t)\n", - " polys = []\n", - " for i in range(len(t_grid) - 1):\n", - " quad = [\n", - " (power_c[i], heat_c[i], 0.0),\n", - " (power_c[i + 1], heat_c[i + 1], 0.0),\n", - " (power_c[i + 1], heat_c[i + 1], fuel_c[i + 1]),\n", - " (power_c[i], heat_c[i], fuel_c[i]),\n", - " ]\n", - " polys.append(quad)\n", - " coll = Poly3DCollection(\n", - " polys, facecolor=\"lightsteelblue\", edgecolor=\"none\", alpha=0.35\n", - " )\n", - " ax.add_collection3d(coll)\n", - "\n", - " # Upper boundary: the curve itself\n", - " ax.plot(power_c, heat_c, fuel_c, color=\"C0\", lw=2.5)\n", - " ax.scatter(x_pts_3d, z_pts_3d, y_pts_3d, color=\"C0\", s=40)\n", - "\n", - " # (power, heat) projection at fuel=0\n", - " ax.plot(power_c, heat_c, 0 * fuel_c, color=\"gray\", lw=1, linestyle=\":\", alpha=0.7)\n", - "\n", - " ax.set(xlabel=\"power\", ylabel=\"heat\", zlabel=\"fuel\", title=title)\n", - " ax.view_init(elev=elev, azim=azim)\n", - "\n", - "\n", - "fig = plt.figure(figsize=(13, 5))\n", - "ax1 = fig.add_subplot(131, projection=\"3d\")\n", - "draw_ribbon(ax1, elev=20, azim=-70, title=\"perspective\")\n", - "ax2 = fig.add_subplot(132, projection=\"3d\")\n", - "draw_ribbon(ax2, elev=0, azim=-90, title=\"side (power vs fuel)\")\n", - "ax3 = fig.add_subplot(133, projection=\"3d\")\n", - "draw_ribbon(ax3, elev=90, azim=-90, title=\"top (power vs heat)\")\n", - "plt.tight_layout()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The ribbon has the shape *(input curve)* × *[0, output value]*. Points *above*\n", - "the curve in fuel are infeasible; points *below* are feasible, provided\n", - "`(power, heat)` lies on the curve's projection. If the user tried to set\n", - "`power=50, heat=20` (off-curve), the formulation would be infeasible — the\n", - "inputs must be consistent with a shared $\\lambda$." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## The first-tuple convention\n", - "\n", - "Why does only *one* variable get the sign? Because the math of\n", - "\"bound one output by a function of the others\" has a single inequality\n", - "direction. For 3+ variables:\n", - "\n", - "```python\n", - "m.add_piecewise_formulation(\n", - " (fuel, y_pts), # output — sign\n", - " (power, x_pts), # input — ==\n", - " (heat, z_pts), # input — ==\n", - " sign=\"<=\",\n", - ")\n", - "```\n", - "\n", - "reads as `fuel ≤ g(power, heat)` on the joint curve. All inputs must lie on\n", - "the curve (equality); only the output is bounded.\n", - "\n", - "Allowing arbitrary per-variable signs would open up cases like\n", - "\"`fuel ≤ f(power)` AND `heat ≤ f(power)`\" which is a dominated region, not a\n", - "hypograph — mathematically valid but rarely what users want. Restricting to\n", - "one output keeps the semantics unambiguous." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## When is LP the right choice?\n", - "\n", - "`tangent_lines` imposes the **intersection** of chord inequalities. Whether\n", - "that intersection matches the true hypograph/epigraph of `f` depends on the\n", - "curvature × sign combination:\n", - "\n", - "| curvature | `sign=\"<=\"` | `sign=\">=\"` |\n", - "|-----------|-------------|-------------|\n", - "| **concave** | **hypograph (exact ✓)** | **wrong region** — requires `y ≥ max_k chord_k(x) > f(x)` |\n", - "| **convex** | **wrong region** — requires `y ≤ min_k chord_k(x) < f(x)` | **epigraph (exact ✓)** |\n", - "| linear | exact | exact |\n", - "| mixed (non-convex) | convex hull of `f` (wrong for exact hypograph) | concave hull of `f` (wrong for exact epigraph) |\n", - "\n", - "In the ✗ cases, tangent lines do **not** give a loose relaxation — they give\n", - "a **strictly wrong feasible region** that rejects points satisfying the true\n", - "constraint. Example: for a concave `f` with `y ≥ f(x)`, the chord of any\n", - "segment extrapolated over another segment's x-range lies *above* `f`, so the\n", - "constraint `y ≥ max_k chord_k(x)` forbids `y = f(x)` itself.\n", - "\n", - "`method=\"auto\"` dispatches to LP only in the two **exact** cases (concave+`<=`\n", - "or convex+`>=`). For the other combinations it falls back to SOS2 or\n", - "incremental, which encode the hypograph/epigraph exactly via discrete segment\n", - "selection.\n", - "\n", - "`method=\"lp\"` explicitly forces LP and raises on a mismatched curvature\n", - "rather than silently producing a wrong feasible region.\n", - "\n", - "For **non-convex** curves with either sign, the only exact option is a\n", - "piecewise formulation. That's what `sign=\"<=\"` does internally: it falls\n", - "back to SOS2/incremental with the sign on the output link. No relaxation,\n", - "no wrong bounds.\n", - "\n", - "For **3+ variables** with inequality, LP is never applicable: tangent lines\n", - "express *one input → one output*. With multiple inputs on a 1-D curve in\n", - "N-D space, identifying which segment we're on requires SOS2/binary. Auto\n", - "dispatches to SOS2 here." - ] + "source": "## When is LP the right choice?\n\n`tangent_lines` imposes the **intersection** of chord inequalities. Whether that intersection matches the true hypograph/epigraph of `f` depends on the curvature × sign combination:\n\n| curvature | bounded `<=` | bounded `>=` |\n|-----------|--------------|--------------|\n| **concave** | **hypograph (exact ✓)** | **wrong region** — requires `y ≥ max_k chord_k(x) > f(x)` |\n| **convex** | **wrong region** — requires `y ≤ min_k chord_k(x) < f(x)` | **epigraph (exact ✓)** |\n| linear | exact | exact |\n| mixed (non-convex) | convex hull of `f` (wrong for exact hypograph) | concave hull of `f` (wrong for exact epigraph) |\n\nIn the ✗ cases, tangent lines do **not** give a loose relaxation — they give a **strictly wrong feasible region** that rejects points satisfying the true constraint. Example: for a concave `f` with `y ≥ f(x)`, the chord of any segment extrapolated over another segment's x-range lies *above* `f`, so `y ≥ max_k chord_k(x)` forbids `y = f(x)` itself.\n\n`method=\"auto\"` dispatches to LP only in the two **exact** cases (concave + `<=` or convex + `>=`). For the other combinations it falls back to SOS2 or incremental, which encode the hypograph/epigraph exactly via discrete segment selection.\n\n`method=\"lp\"` explicitly forces LP and raises on a mismatched curvature rather than silently producing a wrong feasible region.\n\nFor **non-convex** curves with either sign, the only exact option is a piecewise formulation. That's what the bounded-tuple path does internally: it falls back to SOS2/incremental with the sign on the bounded link. No relaxation, no wrong bounds." }, { "cell_type": "code", @@ -458,51 +136,12 @@ } }, "outputs": [], - "source": [ - "# 1. Non-convex curve: auto falls back (LP relaxation would be loose)\n", - "x_nc = [0, 10, 20, 30]\n", - "y_nc = [0, 20, 10, 30] # slopes change sign → mixed convexity\n", - "\n", - "m1 = linopy.Model()\n", - "x1 = m1.add_variables(lower=0, upper=30, name=\"x\")\n", - "y1 = m1.add_variables(lower=0, upper=40, name=\"y\")\n", - "f1 = m1.add_piecewise_formulation((y1, y_nc), (x1, x_nc), sign=\"<=\")\n", - "print(f\"non-convex + '<=' → {f1.method}\")\n", - "\n", - "# 2. Concave curve + sign='>=': LP would be loose → auto falls back to MIP\n", - "x_cc = [0, 10, 20, 30]\n", - "y_cc = [0, 20, 30, 35] # concave\n", - "\n", - "m2 = linopy.Model()\n", - "x2 = m2.add_variables(lower=0, upper=30, name=\"x\")\n", - "y2 = m2.add_variables(lower=0, upper=40, name=\"y\")\n", - "f2 = m2.add_piecewise_formulation((y2, y_cc), (x2, x_cc), sign=\">=\")\n", - "print(f\"concave + '>=' → {f2.method}\")\n", - "\n", - "# 3. Explicit method=\"lp\" with mismatched curvature raises\n", - "try:\n", - " m3 = linopy.Model()\n", - " x3 = m3.add_variables(lower=0, upper=30, name=\"x\")\n", - " y3 = m3.add_variables(lower=0, upper=40, name=\"y\")\n", - " m3.add_piecewise_formulation((y3, y_cc), (x3, x_cc), sign=\">=\", method=\"lp\")\n", - "except ValueError as e:\n", - " print(f\"lp(concave, '>=') → raises: {e}\")" - ] + "source": "# 1. Non-convex curve: auto falls back (LP relaxation would be loose)\nx_nc = [0, 10, 20, 30]\ny_nc = [0, 20, 10, 30] # slopes change sign → mixed convexity\n\nm1 = linopy.Model()\nx1 = m1.add_variables(lower=0, upper=30, name=\"x\")\ny1 = m1.add_variables(lower=0, upper=40, name=\"y\")\nf1 = m1.add_piecewise_formulation((y1, y_nc, \"<=\"), (x1, x_nc))\nprint(f\"non-convex + '<=' → {f1.method}\")\n\n# 2. Concave curve + sign='>=': LP would be loose → auto falls back to MIP\nx_cc = [0, 10, 20, 30]\ny_cc = [0, 20, 30, 35] # concave\n\nm2 = linopy.Model()\nx2 = m2.add_variables(lower=0, upper=30, name=\"x\")\ny2 = m2.add_variables(lower=0, upper=40, name=\"y\")\nf2 = m2.add_piecewise_formulation((y2, y_cc, \">=\"), (x2, x_cc))\nprint(f\"concave + '>=' → {f2.method}\")\n\n# 3. Explicit method=\"lp\" with mismatched curvature raises\ntry:\n m3 = linopy.Model()\n x3 = m3.add_variables(lower=0, upper=30, name=\"x\")\n y3 = m3.add_variables(lower=0, upper=40, name=\"y\")\n m3.add_piecewise_formulation((y3, y_cc, \">=\"), (x3, x_cc), method=\"lp\")\nexcept ValueError as e:\n print(f\"lp(concave, '>=') → raises: {e}\")" }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "## Summary\n", - "\n", - "- Use `sign=\"=\"` (default) for exact equality on the curve.\n", - "- Use `sign=\"<=\"` / `sign=\">=\"` for one-sided bounds on the first tuple's\n", - " expression.\n", - "- `method=\"auto\"` picks the most efficient formulation: LP for convex/concave\n", - " 2-variable inequalities, otherwise SOS2 or incremental.\n", - "- Only the *first* tuple gets the sign — all other tuples are always\n", - " equality. This restriction keeps semantics unambiguous." - ] + "source": "## Summary\n\n- Default is all-equality: every tuple lies on the curve.\n- Append `\"<=\"` or `\">=\"` as a third tuple element to mark one expression as bounded by the curve.\n- `method=\"auto\"` picks the most efficient formulation: LP for matching-curvature 2-variable inequalities, otherwise SOS2 or incremental.\n- At most one tuple may be bounded; with 3+ tuples all must be equality. Multi-bounded and N≥3 inequality use cases — please open an issue at https://github.com/PyPSA/linopy/issues so we can scope them." } ], "metadata": { @@ -518,4 +157,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index a8035e50..804918af 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -186,18 +186,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "## 4. Inequality bounds — `sign=\"<=\"`\n", - "\n", - "`sign=\"<=\"` / `\">=\"` relaxes **one** tuple into a one-sided bound; the remaining N−1 tuples stay pinned to the curve and move together along it in lockstep.\n", - "\n", - "- With 2 tuples, this is the familiar hypograph `{(x, y) : y ≤ f(x)}`.\n", - "- With 3+ tuples, the N−1 \"pinned\" inputs cannot be constrained independently — they share a single curve-segment position.\n", - "\n", - "On a concave curve with `<=` (or convex with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation — no binaries, no SOS2. The first tuple is the bounded output.\n", - "\n", - "See the [inequality bounds notebook](piecewise-inequality-bounds-tutorial) for mismatched curvature, auto-dispatch fallbacks, and more geometry." - ] + "source": "## 4. Inequality bounds — per-tuple sign\n\nAppend a third tuple element (`\"<=\"` or `\">=\"`) to mark a single expression as **bounded** by the piecewise curve instead of pinned to it. The other tuples stay on the curve. The 2-variable hypograph (`y ≤ f(x)`) and epigraph (`y ≥ f(x)`) are the canonical cases.\n\nOn a concave curve with `<=` (or convex with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation — no binaries, no SOS2.\n\nAt most one tuple may carry a non-equality sign. With 3 or more tuples, all signs must be `\"==\"`; the multi-input bounded case is reserved for a future bivariate / triangulated piecewise API.\n\nSee the [inequality bounds notebook](piecewise-inequality-bounds-tutorial) for mismatched curvature, auto-dispatch fallbacks, and more geometry." }, { "cell_type": "code", @@ -209,68 +198,7 @@ } }, "outputs": [], - "source": [ - "m = linopy.Model()\n", - "power = m.add_variables(name=\"power\", lower=0, upper=120, coords=[time])\n", - "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", - "\n", - "# concave curve: diminishing marginal fuel per MW\n", - "pwf = m.add_piecewise_formulation(\n", - " (fuel, [0, 50, 90, 120]), # bounded output (listed FIRST)\n", - " (power, [0, 40, 80, 120]),\n", - " sign=\"<=\",\n", - ")\n", - "m.add_constraints(power == xr.DataArray([30, 80, 100], coords=[time]))\n", - "m.add_objective(-fuel.sum()) # push fuel against the bound\n", - "m.solve(reformulate_sos=\"auto\")\n", - "\n", - "print(f\"resolved method={pwf.method}, curvature={pwf.convexity}\")\n", - "m.solution[[\"power\", \"fuel\"]].to_pandas()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**3-variable case — CHP plant with heat rejection**\n", - "\n", - "A CHP plant is characterised by a 1-parameter family of operating points (the load parameter). Power, fuel and heat are joint outputs of that parameter, tracing the characteristic curve simultaneously.\n", - "\n", - "For the inequality formulation to be physically meaningful, the first (bounded) tuple must correspond to a quantity with an available dissipation mechanism. A canonical example is **heat rejection** (also called *thermal curtailment*): when downstream heating demand falls below the plant's co-generation capacity at its committed electrical output, excess thermal output is rejected via a cooling tower. Electrical output and fuel draw remain pinned to the load parameter; heat delivery can be anywhere from the rejection floor up to the characteristic curve.\n", - "\n", - "Other admissible choices for the bounded tuple: electrical curtailment, emissions after post-treatment. Placing a consumption-side variable (such as fuel intake) in the bounded position yields a valid but *loose* formulation — safe only when no objective rewards driving it below the curve.\n", - "\n", - "Inequality formulations can also be faster to solve than the equality variant (see *Choice of bounded tuple* in the reference page), so the speed-vs-tightness trade-off is worth weighing even when the physics is strictly equality.\n", - "\n", - "Below: `heat` is the bounded output (rejection); `power` and `fuel` are pinned to the characteristic curve." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "m = linopy.Model()\n", - "power = m.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", - "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", - "heat = m.add_variables(name=\"heat\", lower=0, coords=[time])\n", - "\n", - "# bounded output listed FIRST (heat rejection); power, fuel pinned to the curve\n", - "m.add_piecewise_formulation(\n", - " (heat, [0, 25, 55, 95]), # bounded above — heat rejection\n", - " (power, [0, 30, 60, 100]), # pinned — electrical output at load\n", - " (fuel, [0, 40, 85, 160]), # pinned — fuel draw at load\n", - " sign=\"<=\",\n", - " method=\"sos2\",\n", - ")\n", - "# fix the load via a power target — remaining outputs are determined\n", - "m.add_constraints(power == xr.DataArray([30, 60, 100], coords=[time]))\n", - "m.add_objective(-heat.sum()) # maximise heat — no rejection required\n", - "m.solve(reformulate_sos=\"auto\")\n", - "\n", - "m.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas()" - ] + "source": "m = linopy.Model()\npower = m.add_variables(name=\"power\", lower=0, upper=120, coords=[time])\nfuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n\n# concave curve: diminishing marginal fuel per MW\npwf = m.add_piecewise_formulation(\n (fuel, [0, 50, 90, 120], \"<=\"), # bounded above by the curve\n (power, [0, 40, 80, 120]), # pinned to the curve\n)\nm.add_constraints(power == xr.DataArray([30, 80, 100], coords=[time]))\nm.add_objective(-fuel.sum()) # push fuel against the bound\nm.solve(reformulate_sos=\"auto\")\n\nprint(f\"resolved method={pwf.method}, curvature={pwf.convexity}\")\nm.solution[[\"power\", \"fuel\"]].to_pandas()" }, { "cell_type": "markdown", @@ -411,4 +339,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 0903f0a9..b29fa007 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -799,10 +799,11 @@ def add_piecewise_formulation( - At most one tuple may carry a non-equality sign. All other tuples default to ``"=="``. - - With **3 or more** tuples, all signs must be ``"=="`` (the - multi-input bounded case is not supported yet — the natural reading - ``z ≥ f(x, y)`` belongs to a future bivariate / triangulated - piecewise API). + - With **3 or more** tuples, all signs must be ``"=="``. + + Multi-bounded and N≥3-inequality use cases aren't supported yet. If + you have a concrete use case, please open an issue at + https://github.com/PyPSA/linopy/issues so we can scope it properly. Parameters ---------- @@ -919,8 +920,8 @@ def add_piecewise_formulation( raise ValueError( "Non-equality signs are not supported with 3+ tuples. " "Use sign='==' on all tuples (the default), or reduce to 2 tuples. " - "The multi-input bounded case is reserved for a future " - "bivariate / triangulated piecewise API." + "If you have a concrete use case, please open an issue at " + "https://github.com/PyPSA/linopy/issues." ) signed_idx: int | None From 78d3192cd2f3ad7378fe6e2ff992792cc65674c2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:11:33 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- examples/piecewise-inequality-bounds.ipynb | 2 +- examples/piecewise-linear-constraints.ipynb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/piecewise-inequality-bounds.ipynb b/examples/piecewise-inequality-bounds.ipynb index 2fa929ed..be5746f0 100644 --- a/examples/piecewise-inequality-bounds.ipynb +++ b/examples/piecewise-inequality-bounds.ipynb @@ -157,4 +157,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} \ No newline at end of file +} diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 804918af..be891b85 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -339,4 +339,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} \ No newline at end of file +}