diff --git a/doc/api.rst b/doc/api.rst
index 1554ce60..1ad7d869 100644
--- a/doc/api.rst
+++ b/doc/api.rst
@@ -19,9 +19,9 @@ Creating a model
model.Model.add_constraints
model.Model.add_objective
model.Model.add_piecewise_constraints
- piecewise.piecewise
piecewise.breakpoints
piecewise.segments
+ piecewise.tangent_lines
model.Model.linexpr
model.Model.remove_constraints
model.Model.copy
diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst
index 9278248a..bb9eebbd 100644
--- a/doc/piecewise-linear-constraints.rst
+++ b/doc/piecewise-linear-constraints.rst
@@ -7,13 +7,11 @@ Piecewise linear (PWL) constraints approximate nonlinear functions as connected
linear segments, allowing you to model cost curves, efficiency curves, or
production functions within a linear programming framework.
-Use :py:func:`~linopy.piecewise.piecewise` to describe the function and
-:py:meth:`~linopy.model.Model.add_piecewise_constraints` to add it to a model.
-
.. contents::
:local:
:depth: 2
+
Quick Start
-----------
@@ -22,560 +20,376 @@ Quick Start
import linopy
m = linopy.Model()
- x = m.add_variables(name="x", lower=0, upper=100)
- y = m.add_variables(name="y")
+ power = m.add_variables(name="power", lower=0, upper=100)
+ fuel = m.add_variables(name="fuel")
- # y equals a piecewise linear function of x
- x_pts = linopy.breakpoints([0, 30, 60, 100])
- y_pts = linopy.breakpoints([0, 36, 84, 170])
+ # Link power and fuel via a piecewise linear curve
+ m.add_piecewise_constraints(
+ (power, [0, 30, 60, 100]),
+ (fuel, [0, 36, 84, 170]),
+ )
- m.add_piecewise_constraints(linopy.piecewise(x, x_pts, y_pts) == y)
+Each ``(expression, breakpoints)`` tuple pairs a variable with its
+breakpoint values. All tuples share interpolation weights, so at any
+feasible point, every variable is interpolated between the *same* pair
+of adjacent breakpoints.
-The ``piecewise()`` call creates a lazy descriptor. Comparing it with a
-variable (``==``, ``<=``, ``>=``) produces a
-:class:`~linopy.piecewise.PiecewiseConstraintDescriptor` that
-``add_piecewise_constraints`` knows how to process.
-.. note::
+API
+---
- The ``piecewise(...)`` expression can appear on either side of the
- comparison operator. These forms are equivalent::
+``add_piecewise_constraints``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- piecewise(x, x_pts, y_pts) == y
- y == piecewise(x, x_pts, y_pts)
+.. code-block:: python
+ m.add_piecewise_constraints(
+ (expr1, breakpoints1),
+ (expr2, breakpoints2),
+ ...,
+ method="auto", # "auto", "sos2", or "incremental"
+ active=None, # binary variable to gate the constraint
+ name=None, # base name for generated variables/constraints
+ )
-Formulations
-------------
+Creates auxiliary variables and constraints that enforce all expressions
+to lie exactly on the piecewise curve. Requires a MIP or SOS2-capable
+solver.
-SOS2 (Convex Combination)
-~~~~~~~~~~~~~~~~~~~~~~~~~
-
-Given breakpoints :math:`b_0, b_1, \ldots, b_n`, the SOS2 formulation
-introduces interpolation variables :math:`\lambda_i` such that:
+``tangent_lines``
+~~~~~~~~~~~~~~~~~
-.. math::
+.. code-block:: python
- \lambda_i \in [0, 1], \quad
- \sum_{i=0}^{n} \lambda_i = 1, \quad
- x = \sum_{i=0}^{n} \lambda_i \, b_i
+ t = linopy.tangent_lines(x, x_points, y_points)
-The SOS2 constraint ensures that **at most two adjacent** :math:`\lambda_i` can
-be non-zero, so :math:`x` is interpolated within one segment.
+Returns a :class:`~linopy.expressions.LinearExpression` with one tangent
+line per segment. **No variables are created** --- the result is pure
+linear algebra. Use it with regular ``add_constraints``:
-.. note::
+.. code-block:: python
- SOS2 is a combinatorial constraint handled via branch-and-bound, similar to
- integer variables. Prefer the incremental method
- (``method="incremental"`` or ``method="auto"``) when breakpoints are
- monotonic.
+ t = linopy.tangent_lines(power, x_pts, y_pts)
+ m.add_constraints(fuel <= t) # upper bound
+ m.add_constraints(fuel >= t) # lower bound
-Incremental (Delta) Formulation
+``breakpoints`` and ``segments``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-For **strictly monotonic** breakpoints :math:`b_0 < b_1 < \cdots < b_n`, the
-incremental formulation uses fill-fraction variables:
+Factory functions that create DataArrays with the correct dimension names:
-.. math::
+.. code-block:: python
- \delta_i \in [0, 1], \quad
- \delta_{i+1} \le \delta_i, \quad
- x = b_0 + \sum_{i=1}^{n} \delta_i \, (b_i - b_{i-1})
+ linopy.breakpoints([0, 50, 100]) # list
+ linopy.breakpoints({"gen1": [0, 50], "gen2": [0, 80]}, dim="gen") # per-entity
+ linopy.breakpoints(slopes=[1.2, 1.4], x_points=[0, 30, 60], y0=0) # from slopes
+ linopy.segments([(0, 10), (50, 100)]) # disjunctive
+ linopy.segments({"gen1": [(0, 10)], "gen2": [(0, 80)]}, dim="gen") # per-entity
-The filling-order constraints enforce that segment :math:`i+1` cannot be
-partially filled unless segment :math:`i` is completely filled. Binary
-indicator variables enforce integrality.
-**Limitation:** Breakpoints must be strictly monotonic. For non-monotonic
-curves, use SOS2.
+When to Use What
+----------------
-LP (Tangent-Line) Formulation
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+linopy provides two distinct tools for piecewise linear modelling.
-For **inequality** constraints where the function is **convex** (for ``>=``)
-or **concave** (for ``<=``), a pure LP formulation adds one tangent-line
-constraint per segment — no SOS2 or binary variables needed.
+.. list-table::
+ :header-rows: 1
+ :widths: 30 35 35
-.. math::
+ * -
+ - ``add_piecewise_constraints``
+ - ``tangent_lines``
+ * - **Constraint type**
+ - Equality: :math:`y = f(x)`
+ - Inequality: :math:`y \le f(x)` or :math:`y \ge f(x)`
+ * - **Creates variables?**
+ - Yes (lambdas, deltas, binaries)
+ - No
+ * - **Solver requirement**
+ - MIP or SOS2-capable
+ - Any LP solver
+ * - **N-variable support**
+ - Yes
+ - No (2-variable only)
- y \le m_k \, x + c_k \quad \text{for each segment } k \text{ (concave case)}
+.. warning::
-Domain bounds :math:`x_{\min} \le x \le x_{\max}` are added automatically.
+ ``tangent_lines`` does **not** work with equality. Writing
+ ``fuel == tangent_lines(...)`` creates one equality per segment,
+ which is overconstrained (infeasible except at breakpoints).
+ Use ``add_piecewise_constraints`` for equality.
-**Limitation:** Only valid for inequality constraints with the correct
-convexity; not valid for equality constraints.
+**When is the tangent-line bound tight?**
-Disjunctive (Disaggregated Convex Combination)
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+- :math:`y \le f(x)` is tight when *f* is **concave** (slopes decrease)
+- :math:`y \ge f(x)` is tight when *f* is **convex** (slopes increase)
-For **disconnected segments** (with gaps), the disjunctive formulation selects
-exactly one segment via binary indicators and applies SOS2 within it. No big-M
-constants are needed, giving a tight LP relaxation.
+For other combinations the bound is valid but loose (a relaxation).
-Given :math:`K` segments, each with breakpoints :math:`b_{k,0}, \ldots, b_{k,n_k}`:
-.. math::
+Breakpoint Construction
+-----------------------
- y_k \in \{0, 1\}, \quad \sum_{k} y_k = 1
+From lists
+~~~~~~~~~~
- \lambda_{k,i} \in [0, 1], \quad
- \sum_{i} \lambda_{k,i} = y_k, \quad
- x = \sum_{k} \sum_{i} \lambda_{k,i} \, b_{k,i}
+The simplest form --- pass Python lists directly in the tuple:
+.. code-block:: python
-.. _choosing-a-formulation:
+ m.add_piecewise_constraints(
+ (power, [0, 30, 60, 100]),
+ (fuel, [0, 36, 84, 170]),
+ )
-Choosing a Formulation
-~~~~~~~~~~~~~~~~~~~~~~
+With the ``breakpoints()`` factory
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-Pass ``method="auto"`` (the default) and linopy will pick the best
-formulation automatically:
+Equivalent, but explicit about the DataArray construction:
-- **Equality + monotonic x** → incremental
-- **Inequality + correct convexity** → LP
-- Otherwise → SOS2
-- Disjunctive (segments) → always SOS2 with binary selection
+.. code-block:: python
-.. list-table::
- :header-rows: 1
- :widths: 25 20 20 15 20
-
- * - Property
- - SOS2
- - Incremental
- - LP
- - Disjunctive
- * - Segments
- - Connected
- - Connected
- - Connected
- - Disconnected
- * - Constraint type
- - ``==``, ``<=``, ``>=``
- - ``==``, ``<=``, ``>=``
- - ``<=``, ``>=`` only
- - ``==``, ``<=``, ``>=``
- * - Breakpoint order
- - Any
- - Strictly monotonic
- - Strictly increasing
- - Any (per segment)
- * - Convexity requirement
- - None
- - None
- - Concave (≤) or convex (≥)
- - None
- * - Variable types
- - Continuous + SOS2
- - Continuous + binary
- - Continuous only
- - Binary + SOS2
- * - Solver support
- - SOS2-capable
- - MIP-capable
- - **Any LP solver**
- - SOS2 + MIP
-
-
-Basic Usage
------------
+ m.add_piecewise_constraints(
+ (power, linopy.breakpoints([0, 30, 60, 100])),
+ (fuel, linopy.breakpoints([0, 36, 84, 170])),
+ )
-Equality constraint
-~~~~~~~~~~~~~~~~~~~
+From slopes
+~~~~~~~~~~~
-Link ``y`` to a piecewise linear function of ``x``:
+When you know marginal costs (slopes) rather than absolute values:
.. code-block:: python
- import linopy
-
- m = linopy.Model()
- x = m.add_variables(name="x", lower=0, upper=100)
- y = m.add_variables(name="y")
-
- x_pts = linopy.breakpoints([0, 30, 60, 100])
- y_pts = linopy.breakpoints([0, 36, 84, 170])
-
- m.add_piecewise_constraints(linopy.piecewise(x, x_pts, y_pts) == y)
+ m.add_piecewise_constraints(
+ (power, [0, 50, 100, 150]),
+ (
+ cost,
+ linopy.breakpoints(
+ slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0
+ ),
+ ),
+ )
+ # cost breakpoints: [0, 55, 130, 225]
-Inequality constraints
+Per-entity breakpoints
~~~~~~~~~~~~~~~~~~~~~~
-Use ``<=`` or ``>=`` to bound ``y`` by the piecewise function:
+Different generators can have different curves. Pass a dict to
+``breakpoints()`` with entity names as keys:
.. code-block:: python
- pw = linopy.piecewise(x, x_pts, y_pts)
+ m.add_piecewise_constraints(
+ (
+ power,
+ linopy.breakpoints(
+ {"gas": [0, 30, 60, 100], "coal": [0, 50, 100, 150]}, dim="gen"
+ ),
+ ),
+ (
+ fuel,
+ linopy.breakpoints(
+ {"gas": [0, 40, 90, 180], "coal": [0, 55, 130, 225]}, dim="gen"
+ ),
+ ),
+ )
- # y must be at most the piecewise function of x (pw >= y ↔ y <= pw)
- m.add_piecewise_constraints(pw >= y)
+Ragged lengths are NaN-padded automatically. Breakpoints are
+auto-broadcast over remaining dimensions (e.g. ``time``).
- # y must be at least the piecewise function of x (pw <= y ↔ y >= pw)
- m.add_piecewise_constraints(pw <= y)
+Disjunctive segments
+~~~~~~~~~~~~~~~~~~~~~
-Choosing a method
-~~~~~~~~~~~~~~~~~
+For disconnected operating regions (e.g. forbidden zones), use
+``segments()``:
.. code-block:: python
- pw = linopy.piecewise(x, x_pts, y_pts)
-
- # Explicit SOS2
- m.add_piecewise_constraints(pw == y, method="sos2")
+ m.add_piecewise_constraints(
+ (power, linopy.segments([(0, 0), (50, 80)])),
+ (cost, linopy.segments([(0, 0), (125, 200)])),
+ )
- # Explicit incremental (requires monotonic x_pts)
- m.add_piecewise_constraints(pw == y, method="incremental")
+The disjunctive formulation is selected automatically when breakpoints
+have a segment dimension.
- # Explicit LP (requires inequality + correct convexity + increasing x_pts)
- m.add_piecewise_constraints(pw >= y, method="lp")
+N-variable linking
+~~~~~~~~~~~~~~~~~~
- # Auto-select best method (default)
- m.add_piecewise_constraints(pw == y, method="auto")
+Link any number of variables through shared breakpoints. All variables
+are symmetric --- there is no distinguished "x" or "y":
-Disjunctive (disconnected segments)
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+.. code-block:: python
-Use :func:`~linopy.piecewise.segments` to define breakpoints with gaps:
+ m.add_piecewise_constraints(
+ (power, [0, 30, 60, 100]),
+ (fuel, [0, 40, 85, 160]),
+ (heat, [0, 25, 55, 95]),
+ )
-.. code-block:: python
- m = linopy.Model()
- x = m.add_variables(name="x", lower=0, upper=100)
- y = m.add_variables(name="y")
+Formulation Methods
+-------------------
- # Two disconnected segments: [0,10] and [50,100]
- x_seg = linopy.segments([(0, 10), (50, 100)])
- y_seg = linopy.segments([(0, 15), (60, 130)])
+Pass ``method="auto"`` (the default) and linopy picks the best
+formulation automatically:
- m.add_piecewise_constraints(linopy.piecewise(x, x_seg, y_seg) == y)
+- **All breakpoints monotonic** --- incremental
+- **Otherwise** --- SOS2
+- **Disjunctive** (segments) --- always SOS2 with binary selection
-The disjunctive formulation is selected automatically when
-``x_points`` / ``y_points`` have a segment dimension (created by
-:func:`~linopy.piecewise.segments`).
+SOS2 (Convex Combination)
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+Works for any breakpoint ordering. Introduces interpolation weights
+:math:`\lambda_i` with an SOS2 adjacency constraint:
-Breakpoints Factory
--------------------
+.. math::
-The :func:`~linopy.piecewise.breakpoints` factory creates DataArrays with
-the correct ``_breakpoint`` dimension. It accepts several input types
-(``BreaksLike``):
+ &\sum_{i=0}^{n} \lambda_i = 1, \qquad
+ \text{SOS2}(\lambda_0, \ldots, \lambda_n)
-From a list
-~~~~~~~~~~~
+ &e_j = \sum_{i=0}^{n} \lambda_i \, B_{j,i}
+ \quad \text{for each expression } j
-.. code-block:: python
+The SOS2 constraint ensures at most two adjacent :math:`\lambda_i` are
+non-zero, so every expression is interpolated within the same segment.
- # 1D breakpoints (dims: [_breakpoint])
- bp = linopy.breakpoints([0, 50, 100])
+.. note::
-From a pandas Series
-~~~~~~~~~~~~~~~~~~~~
+ SOS2 is handled via branch-and-bound, similar to integer variables.
+ Prefer ``method="incremental"`` when breakpoints are monotonic.
.. code-block:: python
- import pandas as pd
+ m.add_piecewise_constraints((power, xp), (fuel, yp), method="sos2")
- bp = linopy.breakpoints(pd.Series([0, 50, 100]))
+Incremental (Delta) Formulation
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-From a DataFrame (per-entity, requires ``dim``)
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+For **strictly monotonic** breakpoints. Uses fill-fraction variables
+:math:`\delta_i` with binary indicators --- no SOS2 needed:
-.. code-block:: python
+.. math::
- # rows = entities, columns = breakpoints
- df = pd.DataFrame(
- {"bp0": [0, 0], "bp1": [50, 80], "bp2": [100, float("nan")]},
- index=["gen1", "gen2"],
- )
- bp = linopy.breakpoints(df, dim="generator")
+ &\delta_i \in [0, 1], \quad \delta_{i+1} \le \delta_i
-From a dict (per-entity, ragged lengths allowed)
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ &e_j = B_{j,0} + \sum_{i=1}^{n} \delta_i \, (B_{j,i} - B_{j,i-1})
.. code-block:: python
- # NaN-padded to the longest entry
- bp = linopy.breakpoints(
- {"gen1": [0, 50, 100], "gen2": [0, 80]},
- dim="generator",
- )
+ m.add_piecewise_constraints((power, xp), (fuel, yp), method="incremental")
-From a DataArray (pass-through)
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+**Limitation:** All breakpoint sequences must be strictly monotonic.
-.. code-block:: python
+Disjunctive (Disaggregated Convex Combination)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- import xarray as xr
+For **disconnected segments** (gaps between operating regions). Binary
+indicators :math:`z_k` select exactly one segment; SOS2 applies within it:
- arr = xr.DataArray([0, 50, 100], dims=["_breakpoint"])
- bp = linopy.breakpoints(arr) # returned as-is
+.. math::
-Slopes mode
-~~~~~~~~~~~
+ &z_k \in \{0, 1\}, \quad \sum_{k} z_k = 1
-Compute y-breakpoints from segment slopes and an initial y-value:
+ &\sum_{i} \lambda_{k,i} = z_k, \qquad
+ e_j = \sum_{k} \sum_{i} \lambda_{k,i} \, B_{j,k,i}
-.. code-block:: python
+No big-M constants are needed, giving a tight LP relaxation.
- y_pts = linopy.breakpoints(
- slopes=[1.2, 1.4, 1.7],
- x_points=[0, 30, 60, 100],
- y0=0,
- )
- # Equivalent to breakpoints([0, 36, 78, 146])
+Tangent lines
+~~~~~~~~~~~~~
+For inequality bounds. Computes one tangent line per segment:
-Segments Factory
-----------------
+.. math::
-The :func:`~linopy.piecewise.segments` factory creates DataArrays with both
-``_segment`` and ``_breakpoint`` dimensions (``SegmentsLike``):
+ \text{tangent}_k(x) = m_k \cdot x + c_k
-From a list of sequences
-~~~~~~~~~~~~~~~~~~~~~~~~
+where :math:`m_k` is the slope and :math:`c_k` the intercept of
+segment :math:`k`. Returns a ``LinearExpression`` --- no variables
+created.
.. code-block:: python
- # dims: [_segment, _breakpoint]
- seg = linopy.segments([(0, 10), (50, 100)])
+ t = linopy.tangent_lines(power, x_pts, y_pts)
+ m.add_constraints(fuel <= t)
-From a dict (per-entity)
-~~~~~~~~~~~~~~~~~~~~~~~~~
-.. code-block:: python
+Advanced Features
+-----------------
- seg = linopy.segments(
- {"gen1": [(0, 10), (50, 100)], "gen2": [(0, 80)]},
- dim="generator",
- )
+Active parameter (unit commitment)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-From a DataFrame
-~~~~~~~~~~~~~~~~
+The ``active`` parameter gates the piecewise function with a binary
+variable. When ``active=0``, all auxiliary variables (and thus the
+linked expressions) are forced to zero:
.. code-block:: python
- # rows = segments, columns = breakpoints
- seg = linopy.segments(pd.DataFrame([[0, 10], [50, 100]]))
+ commit = m.add_variables(name="commit", binary=True, coords=[time])
+ m.add_piecewise_constraints(
+ (power, [30, 60, 100]),
+ (fuel, [40, 90, 170]),
+ active=commit,
+ )
+- ``commit=1``: power operates in [30, 100], fuel = f(power)
+- ``commit=0``: power = 0, fuel = 0
Auto-broadcasting
------------------
+~~~~~~~~~~~~~~~~~
-Breakpoints are automatically broadcast to match the dimensions of the
-expressions. You don't need ``expand_dims`` when your variables have extra
-dimensions (e.g. ``time``):
+Breakpoints are automatically broadcast to match expression dimensions.
+You don't need ``expand_dims``:
.. code-block:: python
- import pandas as pd
- import linopy
-
- m = linopy.Model()
time = pd.Index([1, 2, 3], name="time")
x = m.add_variables(name="x", lower=0, upper=100, coords=[time])
y = m.add_variables(name="y", coords=[time])
# 1D breakpoints auto-expand to match x's time dimension
- x_pts = linopy.breakpoints([0, 50, 100])
- y_pts = linopy.breakpoints([0, 70, 150])
- m.add_piecewise_constraints(linopy.piecewise(x, x_pts, y_pts) == y)
-
-
-Method Signatures
------------------
-
-``piecewise``
-~~~~~~~~~~~~~
+ m.add_piecewise_constraints((x, [0, 50, 100]), (y, [0, 70, 150]))
-.. code-block:: python
-
- linopy.piecewise(expr, x_points, y_points)
-
-- ``expr`` -- ``Variable`` or ``LinearExpression``. The "x" side expression.
-- ``x_points`` -- ``BreaksLike``. Breakpoint x-coordinates.
-- ``y_points`` -- ``BreaksLike``. Breakpoint y-coordinates.
-
-Returns a :class:`~linopy.piecewise.PiecewiseExpression` that supports
-``==``, ``<=``, ``>=`` comparison with another expression.
-
-``add_piecewise_constraints``
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-.. code-block:: python
-
- Model.add_piecewise_constraints(
- descriptor,
- method="auto",
- name=None,
- skip_nan_check=False,
- )
-
-- ``descriptor`` -- :class:`~linopy.piecewise.PiecewiseConstraintDescriptor`.
- Created by comparing a ``PiecewiseExpression`` with an expression, e.g.
- ``piecewise(x, x_pts, y_pts) == y``.
-- ``method`` -- ``"auto"`` (default), ``"sos2"``, ``"incremental"``, or ``"lp"``.
-- ``name`` -- ``str``, optional. Base name for generated variables/constraints.
-- ``skip_nan_check`` -- ``bool``, default ``False``.
-
-Returns a :class:`~linopy.constraints.Constraint`, but the returned object is
-formulation-dependent: typically ``{name}_convex`` (SOS2), ``{name}_fill`` or
-``{name}_y_link`` (incremental), and ``{name}_select`` (disjunctive). For
-inequality constraints, the returned constraint is the core piecewise
-formulation constraint, not ``{name}_ineq``.
-
-``breakpoints``
-~~~~~~~~~~~~~~~~
-
-.. code-block:: python
-
- linopy.breakpoints(values, dim=None)
- linopy.breakpoints(slopes, x_points, y0, dim=None)
-
-- ``values`` -- ``BreaksLike`` (list, Series, DataFrame, DataArray, or dict).
-- ``slopes``, ``x_points``, ``y0`` -- for slopes mode (mutually exclusive with
- ``values``).
-- ``dim`` -- ``str``, required when ``values`` or ``slopes`` is a DataFrame or dict.
+NaN masking
+~~~~~~~~~~~
-``segments``
-~~~~~~~~~~~~~
+Trailing NaN values in breakpoints mask the corresponding lambda/delta
+variables. This is useful for per-entity breakpoints with ragged
+lengths:
.. code-block:: python
- linopy.segments(values, dim=None)
+ # gen1 has 3 breakpoints, gen2 has 2 (NaN-padded)
+ bp = linopy.breakpoints({"gen1": [0, 50, 100], "gen2": [0, 80]}, dim="gen")
-- ``values`` -- ``SegmentsLike`` (list of sequences, DataFrame, DataArray, or
- dict).
-- ``dim`` -- ``str``, required when ``values`` is a dict.
+Interior NaN values (gaps in the middle) are not supported and raise
+an error.
+Generated variables and constraints
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-Generated Variables and Constraints
-------------------------------------
+Given base name ``name``:
-Given base name ``name``, the following objects are created:
-
-**SOS2 method:**
-
-.. list-table::
- :header-rows: 1
- :widths: 30 15 55
-
- * - Name
- - Type
- - Description
- * - ``{name}_lambda``
- - Variable
- - Interpolation weights :math:`\lambda_i \in [0, 1]` (SOS2).
- * - ``{name}_convex``
- - Constraint
- - :math:`\sum_i \lambda_i = 1`.
- * - ``{name}_x_link``
- - Constraint
- - :math:`x = \sum_i \lambda_i \, x_i`.
- * - ``{name}_y_link``
- - Constraint
- - :math:`y = \sum_i \lambda_i \, y_i`.
- * - ``{name}_aux``
- - Variable
- - Auxiliary variable :math:`z` (inequality constraints only).
- * - ``{name}_ineq``
- - Constraint
- - :math:`y \le z` or :math:`y \ge z` (inequality only).
-
-**Incremental method:**
+**SOS2:**
+``{name}_lambda`` (variable), ``{name}_convex`` (constraint),
+``{name}_x_link`` (constraint)
-.. list-table::
- :header-rows: 1
- :widths: 30 15 55
-
- * - Name
- - Type
- - Description
- * - ``{name}_delta``
- - Variable
- - Fill-fraction variables :math:`\delta_i \in [0, 1]`.
- * - ``{name}_inc_binary``
- - Variable
- - Binary indicators for each segment.
- * - ``{name}_inc_link``
- - Constraint
- - :math:`\delta_i \le y_i` (delta bounded by binary).
- * - ``{name}_fill``
- - Constraint
- - :math:`\delta_{i+1} \le \delta_i` (fill order, 3+ breakpoints).
- * - ``{name}_inc_order``
- - Constraint
- - :math:`y_{i+1} \le \delta_i` (binary ordering, 3+ breakpoints).
- * - ``{name}_x_link``
- - Constraint
- - :math:`x = x_0 + \sum_i \delta_i \, \Delta x_i`.
- * - ``{name}_y_link``
- - Constraint
- - :math:`y = y_0 + \sum_i \delta_i \, \Delta y_i`.
- * - ``{name}_aux``
- - Variable
- - Auxiliary variable :math:`z` (inequality constraints only).
- * - ``{name}_ineq``
- - Constraint
- - :math:`y \le z` or :math:`y \ge z` (inequality only).
-
-**LP method:**
+**Incremental:**
+``{name}_delta`` (variable), ``{name}_inc_binary`` (variable),
+``{name}_fill`` (constraint), ``{name}_x_link`` (constraint)
-.. list-table::
- :header-rows: 1
- :widths: 30 15 55
-
- * - Name
- - Type
- - Description
- * - ``{name}_lp``
- - Constraint
- - Tangent-line constraints (one per segment).
- * - ``{name}_lp_domain_lo``
- - Constraint
- - :math:`x \ge x_{\min}`.
- * - ``{name}_lp_domain_hi``
- - Constraint
- - :math:`x \le x_{\max}`.
-
-**Disjunctive method:**
+**Disjunctive:**
+``{name}_binary`` (variable), ``{name}_select`` (constraint),
+``{name}_lambda`` (variable), ``{name}_convex`` (constraint),
+``{name}_x_link`` (constraint)
-.. list-table::
- :header-rows: 1
- :widths: 30 15 55
-
- * - Name
- - Type
- - Description
- * - ``{name}_binary``
- - Variable
- - Segment indicators :math:`y_k \in \{0, 1\}`.
- * - ``{name}_select``
- - Constraint
- - :math:`\sum_k y_k = 1`.
- * - ``{name}_lambda``
- - Variable
- - Per-segment interpolation weights (SOS2).
- * - ``{name}_convex``
- - Constraint
- - :math:`\sum_i \lambda_{k,i} = y_k`.
- * - ``{name}_x_link``
- - Constraint
- - :math:`x = \sum_k \sum_i \lambda_{k,i} \, x_{k,i}`.
- * - ``{name}_y_link``
- - Constraint
- - :math:`y = \sum_k \sum_i \lambda_{k,i} \, y_{k,i}`.
- * - ``{name}_aux``
- - Variable
- - Auxiliary variable :math:`z` (inequality constraints only).
- * - ``{name}_ineq``
- - Constraint
- - :math:`y \le z` or :math:`y \ge z` (inequality only).
See Also
--------
-- :doc:`piecewise-linear-constraints-tutorial` -- Worked examples covering SOS2, incremental, LP, and disjunctive usage
-- :doc:`sos-constraints` -- Low-level SOS1/SOS2 constraint API
-- :doc:`creating-constraints` -- General constraint creation
-- :doc:`user-guide` -- Overall linopy usage patterns
+- :doc:`piecewise-linear-constraints-tutorial` --- Worked examples (notebook)
+- :doc:`sos-constraints` --- Low-level SOS1/SOS2 constraint API
diff --git a/doc/release_notes.rst b/doc/release_notes.rst
index 54c98f43..d1c7efea 100644
--- a/doc/release_notes.rst
+++ b/doc/release_notes.rst
@@ -11,11 +11,11 @@ Upcoming Version
- Comparison operators (``==``, ``<=``, ``>=``) fill missing RHS coords with NaN (no constraint created)
- Fixes crash on ``subset + var`` / ``subset + expr`` reverse addition
- Fixes superset DataArrays expanding result coords beyond the variable's coordinate space
-* Add ``add_piecewise_constraints()`` with SOS2, incremental, LP, and disjunctive formulations (``linopy.piecewise(x, x_pts, y_pts) == y``).
-* Add ``linopy.piecewise()`` to create piecewise linear function descriptors (`PiecewiseExpression`) from separate x/y breakpoint arrays.
+* Add ``add_piecewise_constraints()`` for piecewise linear equality constraints with SOS2, incremental, and disjunctive formulations: ``m.add_piecewise_constraints((power, x_pts), (fuel, y_pts))``. Supports N-variable linking (e.g. CHP with fuel/power/heat), per-entity breakpoints, and unit commitment via the ``active`` parameter.
+* Add ``tangent_lines()`` for piecewise linear inequality bounds — returns a ``LinearExpression`` with one tangent line per segment, no auxiliary variables. Use with regular ``add_constraints``.
* Add ``linopy.breakpoints()`` factory for convenient breakpoint construction from lists, Series, DataFrames, DataArrays, or dicts. Supports slopes mode.
* Add ``linopy.segments()`` factory for disjunctive (disconnected) breakpoints.
-* Add ``active`` parameter to ``piecewise()`` for gating piecewise linear functions with a binary variable (e.g. unit commitment). Supported for incremental, SOS2, and disjunctive methods.
+* Add ``slopes_to_points()`` utility for converting segment slopes to breakpoint y-coordinates.
* Add the `sphinx-copybutton` to the documentation
* Add SOS1 and SOS2 reformulations for solvers not supporting them.
* Add semi-continous variables for solvers that support them
diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb
index 5c85000a..7f6a473a 100644
--- a/examples/piecewise-linear-constraints.ipynb
+++ b/examples/piecewise-linear-constraints.ipynb
@@ -3,7 +3,7 @@
{
"cell_type": "markdown",
"metadata": {},
- "source": "# Piecewise Linear Constraints Tutorial\n\nThis notebook demonstrates linopy's piecewise linear (PWL) constraint formulations.\nEach example builds a separate dispatch model where a single power plant must meet\na time-varying demand.\n\n| Example | Plant | Limitation | Formulation |\n|---------|-------|------------|-------------|\n| 1 | Gas turbine (0\u2013100 MW) | Convex heat rate | SOS2 |\n| 2 | Coal plant (0\u2013150 MW) | Monotonic heat rate | Incremental |\n| 3 | Diesel generator (off or 50\u201380 MW) | Forbidden zone | Disjunctive |\n| 4 | Concave efficiency curve | Inequality bound | LP |\n| 5 | Gas unit with commitment | On/off + min load | Incremental + `active` |\n\n**Note:** The `piecewise(...)` expression can appear on either side of\nthe comparison operator (`==`, `<=`, `>=`). For example, both\n`linopy.piecewise(x, x_pts, y_pts) == y` and `y == linopy.piecewise(...)` work."
+ "source": "# Piecewise Linear Constraints Tutorial\n\nThis notebook demonstrates linopy's piecewise linear (PWL) constraint formulations.\nEach example builds a separate dispatch model where a single power plant must meet\na time-varying demand.\n\n| Example | Plant | Limitation | Formulation |\n|---------|-------|------------|-------------|\n| 1 | Gas turbine (0-100 MW) | Convex heat rate | SOS2 |\n| 2 | Coal plant (0-150 MW) | Monotonic heat rate | Incremental |\n| 3 | Diesel generator (off or 50-80 MW) | Forbidden zone | Disjunctive |\n| 4 | Concave efficiency curve | Inequality bound | Tangent lines |\n| 5 | Gas unit with commitment | On/off + min load | Incremental + `active` |\n| 6 | CHP plant (N-variable) | Joint power/fuel/heat | N-variable SOS2 |\n| 7 | Fleet of generators | Per-entity breakpoints | Per-generator curves |\n\n**API:** Each `(expression, breakpoints)` tuple links a variable to its breakpoints.\nAll tuples share interpolation weights, coupling them on the same curve segment.\n\n```python\nm.add_piecewise_constraints((power, x_pts), (fuel, y_pts))\n```"
},
{
"cell_type": "code",
@@ -16,8 +16,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:29.166974Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:27.800436Z",
- "start_time": "2026-03-09T10:17:27.796927Z"
+ "end_time": "2026-04-01T11:08:36.934172Z",
+ "start_time": "2026-04-01T11:08:36.927037Z"
}
},
"source": [
@@ -30,26 +30,47 @@
"time = pd.Index([1, 2, 3], name=\"time\")\n",
"\n",
"\n",
- "def plot_pwl_results(\n",
- " model, x_pts, y_pts, demand, x_name=\"power\", y_name=\"fuel\", color=\"C0\"\n",
- "):\n",
- " \"\"\"Plot PWL curve with operating points and dispatch vs demand.\"\"\"\n",
+ "def plot_pwl_results(model, breakpoints, demand, *, x_name=\"power\", color=\"C0\"):\n",
+ " \"\"\"\n",
+ " Plot PWL curves with operating points and dispatch vs demand.\n",
+ "\n",
+ " Parameters\n",
+ " ----------\n",
+ " model : linopy.Model\n",
+ " Solved model.\n",
+ " breakpoints : DataArray\n",
+ " Breakpoints array. For 2-variable cases pass a DataArray with a\n",
+ " \"var\" dimension containing two coordinates (x and y variable names).\n",
+ " Alternatively pass two separate arrays and they will be stacked.\n",
+ " demand : DataArray\n",
+ " Demand time series (plotted as step line).\n",
+ " x_name : str\n",
+ " Name of the x-axis variable (used for the curve plot).\n",
+ " color : str\n",
+ " Base color for the plot.\n",
+ " \"\"\"\n",
" sol = model.solution\n",
+ " var_names = list(breakpoints.coords[\"var\"].values)\n",
+ " bp_x = breakpoints.sel(var=x_name).values\n",
+ "\n",
" fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3.5))\n",
"\n",
- " # Left: PWL curve with operating points\n",
- " ax1.plot(\n",
- " x_pts.values.flat, y_pts.values.flat, \"o-\", color=color, label=\"Breakpoints\"\n",
- " )\n",
- " for t in time:\n",
- " ax1.plot(\n",
- " sol[x_name].sel(time=t),\n",
- " sol[y_name].sel(time=t),\n",
- " \"s\",\n",
- " ms=10,\n",
- " label=f\"t={t}\",\n",
- " )\n",
- " ax1.set(xlabel=x_name.title(), ylabel=y_name.title(), title=\"Heat rate curve\")\n",
+ " # Left: breakpoint curves with operating points\n",
+ " colors = [f\"C{i}\" for i in range(len(var_names))]\n",
+ " for var, c in zip(var_names, colors):\n",
+ " if var == x_name:\n",
+ " continue\n",
+ " bp_y = breakpoints.sel(var=var).values\n",
+ " ax1.plot(bp_x, bp_y, \"o-\", color=c, label=f\"{var} (breakpoints)\")\n",
+ " for t in time:\n",
+ " ax1.plot(\n",
+ " float(sol[x_name].sel(time=t)),\n",
+ " float(sol[var].sel(time=t)),\n",
+ " \"D\",\n",
+ " color=c,\n",
+ " ms=10,\n",
+ " )\n",
+ " ax1.set(xlabel=x_name.title(), title=\"PWL curve\")\n",
" ax1.legend()\n",
"\n",
" # Right: dispatch vs demand\n",
@@ -90,7 +111,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "## 1. SOS2 formulation \u2014 Gas turbine\n",
+ "## 1. SOS2 formulation — Gas turbine\n",
"\n",
"The gas turbine has a **convex** heat rate: efficient at moderate load,\n",
"increasingly fuel-hungry at high output. We use the **SOS2** formulation\n",
@@ -108,8 +129,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:29.185683Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:27.808870Z",
- "start_time": "2026-03-09T10:17:27.806626Z"
+ "end_time": "2026-04-01T11:08:36.947252Z",
+ "start_time": "2026-04-01T11:08:36.944290Z"
}
},
"source": [
@@ -132,8 +153,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:29.200161Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:27.851223Z",
- "start_time": "2026-03-09T10:17:27.811464Z"
+ "end_time": "2026-04-01T11:08:36.999555Z",
+ "start_time": "2026-04-01T11:08:36.951114Z"
}
},
"source": [
@@ -142,10 +163,10 @@
"power = m1.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n",
"fuel = m1.add_variables(name=\"fuel\", lower=0, coords=[time])\n",
"\n",
- "# piecewise(...) can be written on either side of the comparison\n",
"# breakpoints are auto-broadcast to match the time dimension\n",
"m1.add_piecewise_constraints(\n",
- " linopy.piecewise(power, x_pts1, y_pts1) == fuel,\n",
+ " (power, x_pts1),\n",
+ " (fuel, y_pts1),\n",
" name=\"pwl\",\n",
" method=\"sos2\",\n",
")\n",
@@ -168,8 +189,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:29.267514Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:27.899254Z",
- "start_time": "2026-03-09T10:17:27.854515Z"
+ "end_time": "2026-04-01T11:08:37.057492Z",
+ "start_time": "2026-04-01T11:08:37.002487Z"
}
},
"source": [
@@ -189,8 +210,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:29.327130Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:27.914316Z",
- "start_time": "2026-03-09T10:17:27.909570Z"
+ "end_time": "2026-04-01T11:08:37.072609Z",
+ "start_time": "2026-04-01T11:08:37.068099Z"
}
},
"source": [
@@ -210,12 +231,13 @@
"shell.execute_reply.started": "2026-03-06T11:51:29.339680Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.025921Z",
- "start_time": "2026-03-09T10:17:27.922945Z"
+ "end_time": "2026-04-01T11:08:37.172658Z",
+ "start_time": "2026-04-01T11:08:37.081859Z"
}
},
"source": [
- "plot_pwl_results(m1, x_pts1, y_pts1, demand1, color=\"C0\")"
+ "bp1 = linopy.breakpoints({\"power\": x_pts1.values, \"fuel\": y_pts1.values}, dim=\"var\")\n",
+ "plot_pwl_results(m1, bp1, demand1, color=\"C0\")"
],
"outputs": [],
"execution_count": null
@@ -224,11 +246,11 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "## 2. Incremental formulation \u2014 Coal plant\n",
+ "## 2. Incremental formulation — Coal plant\n",
"\n",
"The coal plant has a **monotonically increasing** heat rate. Since all\n",
"breakpoints are strictly monotonic, we can use the **incremental**\n",
- "formulation \u2014 which uses fill-fraction variables with binary indicators."
+ "formulation — which uses fill-fraction variables with binary indicators."
]
},
{
@@ -242,8 +264,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:29.490084Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.039245Z",
- "start_time": "2026-03-09T10:17:28.035712Z"
+ "end_time": "2026-04-01T11:08:37.180064Z",
+ "start_time": "2026-04-01T11:08:37.176417Z"
}
},
"source": [
@@ -266,8 +288,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:29.501307Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.121499Z",
- "start_time": "2026-03-09T10:17:28.052395Z"
+ "end_time": "2026-04-01T11:08:37.578537Z",
+ "start_time": "2026-04-01T11:08:37.187530Z"
}
},
"source": [
@@ -276,9 +298,9 @@
"power = m2.add_variables(name=\"power\", lower=0, upper=150, coords=[time])\n",
"fuel = m2.add_variables(name=\"fuel\", lower=0, coords=[time])\n",
"\n",
- "# breakpoints are auto-broadcast to match the time dimension\n",
"m2.add_piecewise_constraints(\n",
- " linopy.piecewise(power, x_pts2, y_pts2) == fuel,\n",
+ " (power, x_pts2),\n",
+ " (fuel, y_pts2),\n",
" name=\"pwl\",\n",
" method=\"incremental\",\n",
")\n",
@@ -301,8 +323,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:29.604427Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.174903Z",
- "start_time": "2026-03-09T10:17:28.124418Z"
+ "end_time": "2026-04-01T11:08:37.626072Z",
+ "start_time": "2026-04-01T11:08:37.583238Z"
}
},
"source": [
@@ -322,8 +344,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:29.681822Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.182912Z",
- "start_time": "2026-03-09T10:17:28.178226Z"
+ "end_time": "2026-04-01T11:08:37.636391Z",
+ "start_time": "2026-04-01T11:08:37.631610Z"
}
},
"source": [
@@ -343,12 +365,13 @@
"shell.execute_reply.started": "2026-03-06T11:51:29.699334Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.285938Z",
- "start_time": "2026-03-09T10:17:28.191498Z"
+ "end_time": "2026-04-01T11:08:37.743315Z",
+ "start_time": "2026-04-01T11:08:37.644492Z"
}
},
"source": [
- "plot_pwl_results(m2, x_pts2, y_pts2, demand2, color=\"C1\")"
+ "bp2 = linopy.breakpoints({\"power\": x_pts2.values, \"fuel\": y_pts2.values}, dim=\"var\")\n",
+ "plot_pwl_results(m2, bp2, demand2, color=\"C1\")"
],
"outputs": [],
"execution_count": null
@@ -357,10 +380,10 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "## 3. Disjunctive formulation \u2014 Diesel generator\n",
+ "## 3. Disjunctive formulation — Diesel generator\n",
"\n",
"The diesel generator has a **forbidden operating zone**: it must either\n",
- "be off (0 MW) or run between 50\u201380 MW. Because of this gap, we use\n",
+ "be off (0 MW) or run between 50–80 MW. Because of this gap, we use\n",
"**disjunctive** piecewise constraints via `linopy.segments()` and add a\n",
"high-cost **backup** source to cover demand when the diesel is off or\n",
"at its maximum.\n",
@@ -380,8 +403,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:29.852387Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.301657Z",
- "start_time": "2026-03-09T10:17:28.294924Z"
+ "end_time": "2026-04-01T11:08:37.762965Z",
+ "start_time": "2026-04-01T11:08:37.758436Z"
}
},
"source": [
@@ -406,8 +429,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:29.866931Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.381180Z",
- "start_time": "2026-03-09T10:17:28.308026Z"
+ "end_time": "2026-04-01T11:08:37.845482Z",
+ "start_time": "2026-04-01T11:08:37.775373Z"
}
},
"source": [
@@ -417,9 +440,9 @@
"cost = m3.add_variables(name=\"cost\", lower=0, coords=[time])\n",
"backup = m3.add_variables(name=\"backup\", lower=0, coords=[time])\n",
"\n",
- "# breakpoints are auto-broadcast to match the time dimension\n",
"m3.add_piecewise_constraints(\n",
- " linopy.piecewise(power, x_seg, y_seg) == cost,\n",
+ " (power, x_seg),\n",
+ " (cost, y_seg),\n",
" name=\"pwl\",\n",
")\n",
"\n",
@@ -441,8 +464,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:29.955741Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.437326Z",
- "start_time": "2026-03-09T10:17:28.384629Z"
+ "end_time": "2026-04-01T11:08:37.920203Z",
+ "start_time": "2026-04-01T11:08:37.848081Z"
}
},
"source": [
@@ -462,8 +485,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:30.028095Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.449248Z",
- "start_time": "2026-03-09T10:17:28.444065Z"
+ "end_time": "2026-04-01T11:08:37.935150Z",
+ "start_time": "2026-04-01T11:08:37.929245Z"
}
},
"source": [
@@ -476,17 +499,386 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "## 4. LP formulation \u2014 Concave efficiency bound\n",
+ "#",
+ "#",
+ " ",
+ "4",
+ ".",
+ " ",
+ "T",
+ "a",
+ "n",
+ "g",
+ "e",
+ "n",
+ "t",
+ " ",
+ "l",
+ "i",
+ "n",
+ "e",
+ "s",
+ " ",
+ "—",
+ " ",
+ "C",
+ "o",
+ "n",
+ "c",
+ "a",
+ "v",
+ "e",
+ " ",
+ "e",
+ "f",
+ "f",
+ "i",
+ "c",
+ "i",
+ "e",
+ "n",
+ "c",
+ "y",
+ " ",
+ "b",
+ "o",
+ "u",
+ "n",
+ "d",
+ "\n",
"\n",
- "When the piecewise function is **concave** and we use a `>=` constraint\n",
- "(i.e. `pw >= y`, meaning y is bounded above by pw), linopy can use a\n",
- "pure **LP** formulation with tangent-line constraints \u2014 no SOS2 or\n",
- "binary variables needed. This is the fastest to solve.\n",
+ "W",
+ "h",
+ "e",
+ "n",
+ " ",
+ "t",
+ "h",
+ "e",
+ " ",
+ "p",
+ "i",
+ "e",
+ "c",
+ "e",
+ "w",
+ "i",
+ "s",
+ "e",
+ " ",
+ "f",
+ "u",
+ "n",
+ "c",
+ "t",
+ "i",
+ "o",
+ "n",
+ " ",
+ "i",
+ "s",
+ " ",
+ "*",
+ "*",
+ "c",
+ "o",
+ "n",
+ "c",
+ "a",
+ "v",
+ "e",
+ "*",
+ "*",
+ " ",
+ "a",
+ "n",
+ "d",
+ " ",
+ "w",
+ "e",
+ " ",
+ "w",
+ "a",
+ "n",
+ "t",
+ " ",
+ "t",
+ "o",
+ " ",
+ "b",
+ "o",
+ "u",
+ "n",
+ "d",
+ " ",
+ "y",
+ " ",
+ "*",
+ "*",
+ "a",
+ "b",
+ "o",
+ "v",
+ "e",
+ "*",
+ "*",
"\n",
- "For this formulation, the x-breakpoints must be in **strictly increasing**\n",
- "order.\n",
+ "(",
+ "i",
+ ".",
+ "e",
+ ".",
+ " ",
+ "`",
+ "y",
+ " ",
+ "<",
+ "=",
+ " ",
+ "f",
+ "(",
+ "x",
+ ")",
+ "`",
+ ")",
+ ",",
+ " ",
+ "w",
+ "e",
+ " ",
+ "c",
+ "a",
+ "n",
+ " ",
+ "u",
+ "s",
+ "e",
+ " ",
+ "`",
+ "t",
+ "a",
+ "n",
+ "g",
+ "e",
+ "n",
+ "t",
+ "_",
+ "l",
+ "i",
+ "n",
+ "e",
+ "s",
+ "`",
+ " ",
+ "t",
+ "o",
+ " ",
+ "g",
+ "e",
+ "t",
+ " ",
+ "p",
+ "e",
+ "r",
+ "-",
+ "s",
+ "e",
+ "g",
+ "m",
+ "e",
+ "n",
+ "t",
+ " ",
+ "l",
+ "i",
+ "n",
+ "e",
+ "a",
+ "r",
"\n",
- "Here we bound fuel consumption *below* a concave efficiency envelope.\n"
+ "e",
+ "x",
+ "p",
+ "r",
+ "e",
+ "s",
+ "s",
+ "i",
+ "o",
+ "n",
+ "s",
+ " ",
+ "a",
+ "n",
+ "d",
+ " ",
+ "a",
+ "d",
+ "d",
+ " ",
+ "t",
+ "h",
+ "e",
+ "m",
+ " ",
+ "a",
+ "s",
+ " ",
+ "r",
+ "e",
+ "g",
+ "u",
+ "l",
+ "a",
+ "r",
+ " ",
+ "c",
+ "o",
+ "n",
+ "s",
+ "t",
+ "r",
+ "a",
+ "i",
+ "n",
+ "t",
+ "s",
+ " ",
+ "—",
+ " ",
+ "n",
+ "o",
+ " ",
+ "S",
+ "O",
+ "S",
+ "2",
+ " ",
+ "o",
+ "r",
+ " ",
+ "b",
+ "i",
+ "n",
+ "a",
+ "r",
+ "y",
+ "\n",
+ "v",
+ "a",
+ "r",
+ "i",
+ "a",
+ "b",
+ "l",
+ "e",
+ "s",
+ " ",
+ "n",
+ "e",
+ "e",
+ "d",
+ "e",
+ "d",
+ ".",
+ " ",
+ "T",
+ "h",
+ "i",
+ "s",
+ " ",
+ "i",
+ "s",
+ " ",
+ "t",
+ "h",
+ "e",
+ " ",
+ "f",
+ "a",
+ "s",
+ "t",
+ "e",
+ "s",
+ "t",
+ " ",
+ "t",
+ "o",
+ " ",
+ "s",
+ "o",
+ "l",
+ "v",
+ "e",
+ ".",
+ "\n",
+ "\n",
+ "H",
+ "e",
+ "r",
+ "e",
+ " ",
+ "w",
+ "e",
+ " ",
+ "b",
+ "o",
+ "u",
+ "n",
+ "d",
+ " ",
+ "f",
+ "u",
+ "e",
+ "l",
+ " ",
+ "c",
+ "o",
+ "n",
+ "s",
+ "u",
+ "m",
+ "p",
+ "t",
+ "i",
+ "o",
+ "n",
+ " ",
+ "*",
+ "b",
+ "e",
+ "l",
+ "o",
+ "w",
+ "*",
+ " ",
+ "a",
+ " ",
+ "c",
+ "o",
+ "n",
+ "c",
+ "a",
+ "v",
+ "e",
+ " ",
+ "e",
+ "f",
+ "f",
+ "i",
+ "c",
+ "i",
+ "e",
+ "n",
+ "c",
+ "y",
+ " ",
+ "c",
+ "u",
+ "r",
+ "v",
+ "e",
+ "."
]
},
{
@@ -500,8 +892,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:30.043484Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.503165Z",
- "start_time": "2026-03-09T10:17:28.458328Z"
+ "end_time": "2026-04-01T11:08:37.974567Z",
+ "start_time": "2026-04-01T11:08:37.947618Z"
}
},
"source": [
@@ -514,11 +906,9 @@
"power = m4.add_variables(name=\"power\", lower=0, upper=120, coords=[time])\n",
"fuel = m4.add_variables(name=\"fuel\", lower=0, coords=[time])\n",
"\n",
- "# pw >= fuel means fuel <= concave_function(power) \u2192 auto-selects LP method\n",
- "m4.add_piecewise_constraints(\n",
- " linopy.piecewise(power, x_pts4, y_pts4) >= fuel,\n",
- " name=\"pwl\",\n",
- ")\n",
+ "# tangent_lines returns one LinearExpression per segment — pure LP, no aux variables\n",
+ "t = linopy.tangent_lines(power, x_pts4, y_pts4)\n",
+ "m4.add_constraints(fuel <= t, name=\"pwl\")\n",
"\n",
"demand4 = xr.DataArray([30, 80, 100], coords=[time])\n",
"m4.add_constraints(power == demand4, name=\"demand\")\n",
@@ -539,8 +929,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:30.113810Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.554560Z",
- "start_time": "2026-03-09T10:17:28.520243Z"
+ "end_time": "2026-04-01T11:08:38.006772Z",
+ "start_time": "2026-04-01T11:08:37.980912Z"
}
},
"source": [
@@ -560,8 +950,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:30.171993Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.563539Z",
- "start_time": "2026-03-09T10:17:28.559654Z"
+ "end_time": "2026-04-01T11:08:38.016635Z",
+ "start_time": "2026-04-01T11:08:38.012572Z"
}
},
"source": [
@@ -581,12 +971,13 @@
"shell.execute_reply.started": "2026-03-06T11:51:30.192590Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.665419Z",
- "start_time": "2026-03-09T10:17:28.575163Z"
+ "end_time": "2026-04-01T11:08:38.127204Z",
+ "start_time": "2026-04-01T11:08:38.036942Z"
}
},
"source": [
- "plot_pwl_results(m4, x_pts4, y_pts4, demand4, color=\"C4\")"
+ "bp4 = linopy.breakpoints({\"power\": x_pts4.values, \"fuel\": y_pts4.values}, dim=\"var\")\n",
+ "plot_pwl_results(m4, bp4, demand4, color=\"C4\")"
],
"outputs": [],
"execution_count": null
@@ -595,7 +986,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "## 5. Slopes mode \u2014 Building breakpoints from slopes\n",
+ "## 5. Slopes mode — Building breakpoints from slopes\n",
"\n",
"Sometimes you know the **slope** of each segment rather than the y-values\n",
"at each breakpoint. The `breakpoints()` factory can compute y-values from\n",
@@ -613,8 +1004,8 @@
"shell.execute_reply.started": "2026-03-06T11:51:30.345513Z"
},
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.673673Z",
- "start_time": "2026-03-09T10:17:28.668792Z"
+ "end_time": "2026-04-01T11:08:38.135897Z",
+ "start_time": "2026-04-01T11:08:38.133078Z"
}
},
"source": [
@@ -628,221 +1019,1917 @@
},
{
"cell_type": "markdown",
- "source": "## 6. Active parameter \u2014 Unit commitment with piecewise efficiency\n\nIn unit commitment problems, a binary variable $u_t$ controls whether a\nunit is **on** or **off**. When off, both power output and fuel consumption\nmust be zero. When on, the unit operates within its piecewise-linear\nefficiency curve between $P_{min}$ and $P_{max}$.\n\nThe `active` parameter on `piecewise()` handles this by gating the\ninternal PWL formulation with the commitment binary:\n\n- **Incremental:** delta bounds tighten from $\\delta_i \\leq 1$ to\n $\\delta_i \\leq u$, and base terms are multiplied by $u$\n- **SOS2:** convexity constraint becomes $\\sum \\lambda_i = u$\n- **Disjunctive:** segment selection becomes $\\sum z_k = u$\n\nThis is the only gating behavior expressible with pure linear constraints.\nSelectively *relaxing* the PWL (letting x, y float freely when off) would\nrequire big-M or indicator constraints.",
- "metadata": {}
+ "metadata": {},
+ "source": [
+ "#",
+ "#",
+ " ",
+ "6",
+ ".",
+ " ",
+ "A",
+ "c",
+ "t",
+ "i",
+ "v",
+ "e",
+ " ",
+ "p",
+ "a",
+ "r",
+ "a",
+ "m",
+ "e",
+ "t",
+ "e",
+ "r",
+ " ",
+ "-",
+ "-",
+ " ",
+ "U",
+ "n",
+ "i",
+ "t",
+ " ",
+ "c",
+ "o",
+ "m",
+ "m",
+ "i",
+ "t",
+ "m",
+ "e",
+ "n",
+ "t",
+ " ",
+ "w",
+ "i",
+ "t",
+ "h",
+ " ",
+ "p",
+ "i",
+ "e",
+ "c",
+ "e",
+ "w",
+ "i",
+ "s",
+ "e",
+ " ",
+ "e",
+ "f",
+ "f",
+ "i",
+ "c",
+ "i",
+ "e",
+ "n",
+ "c",
+ "y",
+ "\n",
+ "\n",
+ "I",
+ "n",
+ " ",
+ "u",
+ "n",
+ "i",
+ "t",
+ " ",
+ "c",
+ "o",
+ "m",
+ "m",
+ "i",
+ "t",
+ "m",
+ "e",
+ "n",
+ "t",
+ " ",
+ "p",
+ "r",
+ "o",
+ "b",
+ "l",
+ "e",
+ "m",
+ "s",
+ ",",
+ " ",
+ "a",
+ " ",
+ "b",
+ "i",
+ "n",
+ "a",
+ "r",
+ "y",
+ " ",
+ "v",
+ "a",
+ "r",
+ "i",
+ "a",
+ "b",
+ "l",
+ "e",
+ " ",
+ "$",
+ "u",
+ "_",
+ "t",
+ "$",
+ " ",
+ "c",
+ "o",
+ "n",
+ "t",
+ "r",
+ "o",
+ "l",
+ "s",
+ " ",
+ "w",
+ "h",
+ "e",
+ "t",
+ "h",
+ "e",
+ "r",
+ " ",
+ "a",
+ "\n",
+ "u",
+ "n",
+ "i",
+ "t",
+ " ",
+ "i",
+ "s",
+ " ",
+ "*",
+ "*",
+ "o",
+ "n",
+ "*",
+ "*",
+ " ",
+ "o",
+ "r",
+ " ",
+ "*",
+ "*",
+ "o",
+ "f",
+ "f",
+ "*",
+ "*",
+ ".",
+ " ",
+ "W",
+ "h",
+ "e",
+ "n",
+ " ",
+ "o",
+ "f",
+ "f",
+ ",",
+ " ",
+ "b",
+ "o",
+ "t",
+ "h",
+ " ",
+ "p",
+ "o",
+ "w",
+ "e",
+ "r",
+ " ",
+ "o",
+ "u",
+ "t",
+ "p",
+ "u",
+ "t",
+ " ",
+ "a",
+ "n",
+ "d",
+ " ",
+ "f",
+ "u",
+ "e",
+ "l",
+ " ",
+ "c",
+ "o",
+ "n",
+ "s",
+ "u",
+ "m",
+ "p",
+ "t",
+ "i",
+ "o",
+ "n",
+ "\n",
+ "m",
+ "u",
+ "s",
+ "t",
+ " ",
+ "b",
+ "e",
+ " ",
+ "z",
+ "e",
+ "r",
+ "o",
+ ".",
+ " ",
+ "W",
+ "h",
+ "e",
+ "n",
+ " ",
+ "o",
+ "n",
+ ",",
+ " ",
+ "t",
+ "h",
+ "e",
+ " ",
+ "u",
+ "n",
+ "i",
+ "t",
+ " ",
+ "o",
+ "p",
+ "e",
+ "r",
+ "a",
+ "t",
+ "e",
+ "s",
+ " ",
+ "w",
+ "i",
+ "t",
+ "h",
+ "i",
+ "n",
+ " ",
+ "i",
+ "t",
+ "s",
+ " ",
+ "p",
+ "i",
+ "e",
+ "c",
+ "e",
+ "w",
+ "i",
+ "s",
+ "e",
+ "-",
+ "l",
+ "i",
+ "n",
+ "e",
+ "a",
+ "r",
+ "\n",
+ "e",
+ "f",
+ "f",
+ "i",
+ "c",
+ "i",
+ "e",
+ "n",
+ "c",
+ "y",
+ " ",
+ "c",
+ "u",
+ "r",
+ "v",
+ "e",
+ " ",
+ "b",
+ "e",
+ "t",
+ "w",
+ "e",
+ "e",
+ "n",
+ " ",
+ "$",
+ "P",
+ "_",
+ "{",
+ "m",
+ "i",
+ "n",
+ "}",
+ "$",
+ " ",
+ "a",
+ "n",
+ "d",
+ " ",
+ "$",
+ "P",
+ "_",
+ "{",
+ "m",
+ "a",
+ "x",
+ "}",
+ "$",
+ ".",
+ "\n",
+ "\n",
+ "T",
+ "h",
+ "e",
+ " ",
+ "`",
+ "a",
+ "c",
+ "t",
+ "i",
+ "v",
+ "e",
+ "`",
+ " ",
+ "k",
+ "e",
+ "y",
+ "w",
+ "o",
+ "r",
+ "d",
+ " ",
+ "o",
+ "n",
+ " ",
+ "`",
+ "a",
+ "d",
+ "d",
+ "_",
+ "p",
+ "i",
+ "e",
+ "c",
+ "e",
+ "w",
+ "i",
+ "s",
+ "e",
+ "_",
+ "c",
+ "o",
+ "n",
+ "s",
+ "t",
+ "r",
+ "a",
+ "i",
+ "n",
+ "t",
+ "s",
+ "(",
+ ")",
+ "`",
+ " ",
+ "h",
+ "a",
+ "n",
+ "d",
+ "l",
+ "e",
+ "s",
+ " ",
+ "t",
+ "h",
+ "i",
+ "s",
+ " ",
+ "b",
+ "y",
+ "\n",
+ "g",
+ "a",
+ "t",
+ "i",
+ "n",
+ "g",
+ " ",
+ "t",
+ "h",
+ "e",
+ " ",
+ "i",
+ "n",
+ "t",
+ "e",
+ "r",
+ "n",
+ "a",
+ "l",
+ " ",
+ "P",
+ "W",
+ "L",
+ " ",
+ "f",
+ "o",
+ "r",
+ "m",
+ "u",
+ "l",
+ "a",
+ "t",
+ "i",
+ "o",
+ "n",
+ " ",
+ "w",
+ "i",
+ "t",
+ "h",
+ " ",
+ "t",
+ "h",
+ "e",
+ " ",
+ "c",
+ "o",
+ "m",
+ "m",
+ "i",
+ "t",
+ "m",
+ "e",
+ "n",
+ "t",
+ " ",
+ "b",
+ "i",
+ "n",
+ "a",
+ "r",
+ "y",
+ ":",
+ "\n",
+ "\n",
+ "-",
+ " ",
+ "*",
+ "*",
+ "I",
+ "n",
+ "c",
+ "r",
+ "e",
+ "m",
+ "e",
+ "n",
+ "t",
+ "a",
+ "l",
+ ":",
+ "*",
+ "*",
+ " ",
+ "d",
+ "e",
+ "l",
+ "t",
+ "a",
+ " ",
+ "b",
+ "o",
+ "u",
+ "n",
+ "d",
+ "s",
+ " ",
+ "t",
+ "i",
+ "g",
+ "h",
+ "t",
+ "e",
+ "n",
+ " ",
+ "f",
+ "r",
+ "o",
+ "m",
+ " ",
+ "$",
+ "\\",
+ "d",
+ "e",
+ "l",
+ "t",
+ "a",
+ "_",
+ "i",
+ " ",
+ "\\",
+ "l",
+ "e",
+ "q",
+ " ",
+ "1",
+ "$",
+ " ",
+ "t",
+ "o",
+ "\n",
+ " ",
+ " ",
+ "$",
+ "\\",
+ "d",
+ "e",
+ "l",
+ "t",
+ "a",
+ "_",
+ "i",
+ " ",
+ "\\",
+ "l",
+ "e",
+ "q",
+ " ",
+ "u",
+ "$",
+ ",",
+ " ",
+ "a",
+ "n",
+ "d",
+ " ",
+ "b",
+ "a",
+ "s",
+ "e",
+ " ",
+ "t",
+ "e",
+ "r",
+ "m",
+ "s",
+ " ",
+ "a",
+ "r",
+ "e",
+ " ",
+ "m",
+ "u",
+ "l",
+ "t",
+ "i",
+ "p",
+ "l",
+ "i",
+ "e",
+ "d",
+ " ",
+ "b",
+ "y",
+ " ",
+ "$",
+ "u",
+ "$",
+ "\n",
+ "-",
+ " ",
+ "*",
+ "*",
+ "S",
+ "O",
+ "S",
+ "2",
+ ":",
+ "*",
+ "*",
+ " ",
+ "c",
+ "o",
+ "n",
+ "v",
+ "e",
+ "x",
+ "i",
+ "t",
+ "y",
+ " ",
+ "c",
+ "o",
+ "n",
+ "s",
+ "t",
+ "r",
+ "a",
+ "i",
+ "n",
+ "t",
+ " ",
+ "b",
+ "e",
+ "c",
+ "o",
+ "m",
+ "e",
+ "s",
+ " ",
+ "$",
+ "\\",
+ "s",
+ "u",
+ "m",
+ " ",
+ "\\",
+ "l",
+ "a",
+ "m",
+ "b",
+ "d",
+ "a",
+ "_",
+ "i",
+ " ",
+ "=",
+ " ",
+ "u",
+ "$",
+ "\n",
+ "-",
+ " ",
+ "*",
+ "*",
+ "D",
+ "i",
+ "s",
+ "j",
+ "u",
+ "n",
+ "c",
+ "t",
+ "i",
+ "v",
+ "e",
+ ":",
+ "*",
+ "*",
+ " ",
+ "s",
+ "e",
+ "g",
+ "m",
+ "e",
+ "n",
+ "t",
+ " ",
+ "s",
+ "e",
+ "l",
+ "e",
+ "c",
+ "t",
+ "i",
+ "o",
+ "n",
+ " ",
+ "b",
+ "e",
+ "c",
+ "o",
+ "m",
+ "e",
+ "s",
+ " ",
+ "$",
+ "\\",
+ "s",
+ "u",
+ "m",
+ " ",
+ "z",
+ "_",
+ "k",
+ " ",
+ "=",
+ " ",
+ "u",
+ "$",
+ "\n",
+ "\n",
+ "T",
+ "h",
+ "i",
+ "s",
+ " ",
+ "i",
+ "s",
+ " ",
+ "t",
+ "h",
+ "e",
+ " ",
+ "o",
+ "n",
+ "l",
+ "y",
+ " ",
+ "g",
+ "a",
+ "t",
+ "i",
+ "n",
+ "g",
+ " ",
+ "b",
+ "e",
+ "h",
+ "a",
+ "v",
+ "i",
+ "o",
+ "r",
+ " ",
+ "e",
+ "x",
+ "p",
+ "r",
+ "e",
+ "s",
+ "s",
+ "i",
+ "b",
+ "l",
+ "e",
+ " ",
+ "w",
+ "i",
+ "t",
+ "h",
+ " ",
+ "p",
+ "u",
+ "r",
+ "e",
+ " ",
+ "l",
+ "i",
+ "n",
+ "e",
+ "a",
+ "r",
+ " ",
+ "c",
+ "o",
+ "n",
+ "s",
+ "t",
+ "r",
+ "a",
+ "i",
+ "n",
+ "t",
+ "s",
+ ".",
+ "\n",
+ "S",
+ "e",
+ "l",
+ "e",
+ "c",
+ "t",
+ "i",
+ "v",
+ "e",
+ "l",
+ "y",
+ " ",
+ "*",
+ "r",
+ "e",
+ "l",
+ "a",
+ "x",
+ "i",
+ "n",
+ "g",
+ "*",
+ " ",
+ "t",
+ "h",
+ "e",
+ " ",
+ "P",
+ "W",
+ "L",
+ " ",
+ "(",
+ "l",
+ "e",
+ "t",
+ "t",
+ "i",
+ "n",
+ "g",
+ " ",
+ "x",
+ ",",
+ " ",
+ "y",
+ " ",
+ "f",
+ "l",
+ "o",
+ "a",
+ "t",
+ " ",
+ "f",
+ "r",
+ "e",
+ "e",
+ "l",
+ "y",
+ " ",
+ "w",
+ "h",
+ "e",
+ "n",
+ " ",
+ "o",
+ "f",
+ "f",
+ ")",
+ " ",
+ "w",
+ "o",
+ "u",
+ "l",
+ "d",
+ "\n",
+ "r",
+ "e",
+ "q",
+ "u",
+ "i",
+ "r",
+ "e",
+ " ",
+ "b",
+ "i",
+ "g",
+ "-",
+ "M",
+ " ",
+ "o",
+ "r",
+ " ",
+ "i",
+ "n",
+ "d",
+ "i",
+ "c",
+ "a",
+ "t",
+ "o",
+ "r",
+ " ",
+ "c",
+ "o",
+ "n",
+ "s",
+ "t",
+ "r",
+ "a",
+ "i",
+ "n",
+ "t",
+ "s",
+ "."
+ ]
},
{
"cell_type": "code",
- "source": "# Unit parameters: operates between 30-100 MW when on\np_min, p_max = 30, 100\nfuel_min, fuel_max = 40, 170\nstartup_cost = 50\n\nx_pts6 = linopy.breakpoints([p_min, 60, p_max])\ny_pts6 = linopy.breakpoints([fuel_min, 90, fuel_max])\nprint(\"Power breakpoints:\", x_pts6.values)\nprint(\"Fuel breakpoints: \", y_pts6.values)",
"metadata": {
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.685034Z",
- "start_time": "2026-03-09T10:17:28.681601Z"
+ "end_time": "2026-04-01T11:08:38.148433Z",
+ "start_time": "2026-04-01T11:08:38.145204Z"
}
},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Power breakpoints: [ 30. 60. 100.]\n",
- "Fuel breakpoints: [ 40. 90. 170.]\n"
- ]
+ "source": [
+ "# Unit parameters: operates between 30-100 MW when on\n",
+ "p_min, p_max = 30, 100\n",
+ "fuel_min, fuel_max = 40, 170\n",
+ "startup_cost = 50\n",
+ "\n",
+ "x_pts6 = linopy.breakpoints([p_min, 60, p_max])\n",
+ "y_pts6 = linopy.breakpoints([fuel_min, 90, fuel_max])\n",
+ "print(\"Power breakpoints:\", x_pts6.values)\n",
+ "print(\"Fuel breakpoints: \", y_pts6.values)"
+ ],
+ "outputs": [],
+ "execution_count": null
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2026-04-01T11:08:38.256777Z",
+ "start_time": "2026-04-01T11:08:38.161249Z"
}
+ },
+ "source": [
+ "m6 = linopy.Model()\n",
+ "\n",
+ "power = m6.add_variables(name=\"power\", lower=0, upper=p_max, coords=[time])\n",
+ "fuel = m6.add_variables(name=\"fuel\", lower=0, coords=[time])\n",
+ "commit = m6.add_variables(name=\"commit\", binary=True, coords=[time])\n",
+ "\n",
+ "# The active parameter gates the PWL with the commitment binary:\n",
+ "# - commit=1: power in [30, 100], fuel = f(power)\n",
+ "# - commit=0: power = 0, fuel = 0\n",
+ "m6.add_piecewise_constraints(\n",
+ " (power, x_pts6),\n",
+ " (fuel, y_pts6),\n",
+ " active=commit,\n",
+ " name=\"pwl\",\n",
+ " method=\"incremental\",\n",
+ ")\n",
+ "\n",
+ "# Demand: low at t=1 (cheaper to stay off), high at t=2,3\n",
+ "demand6 = xr.DataArray([15, 70, 50], coords=[time])\n",
+ "backup = m6.add_variables(name=\"backup\", lower=0, coords=[time])\n",
+ "m6.add_constraints(power + backup >= demand6, name=\"demand\")\n",
+ "\n",
+ "# Objective: fuel + startup cost + backup at $5/MW\n",
+ "m6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())"
],
+ "outputs": [],
"execution_count": null
},
{
"cell_type": "code",
- "source": "m6 = linopy.Model()\n\npower = m6.add_variables(name=\"power\", lower=0, upper=p_max, coords=[time])\nfuel = m6.add_variables(name=\"fuel\", lower=0, coords=[time])\ncommit = m6.add_variables(name=\"commit\", binary=True, coords=[time])\n\n# The active parameter gates the PWL with the commitment binary:\n# - commit=1: power in [30, 100], fuel = f(power)\n# - commit=0: power = 0, fuel = 0\nm6.add_piecewise_constraints(\n linopy.piecewise(power, x_pts6, y_pts6, active=commit) == fuel,\n name=\"pwl\",\n method=\"incremental\",\n)\n\n# Demand: low at t=1 (cheaper to stay off), high at t=2,3\ndemand6 = xr.DataArray([15, 70, 50], coords=[time])\nbackup = m6.add_variables(name=\"backup\", lower=0, coords=[time])\nm6.add_constraints(power + backup >= demand6, name=\"demand\")\n\n# Objective: fuel + startup cost + backup at $5/MW (cheap enough that\n# staying off at low demand beats committing at minimum load)\nm6.add_objective((fuel + startup_cost * commit + 5 * backup).sum())",
"metadata": {
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.787328Z",
- "start_time": "2026-03-09T10:17:28.697214Z"
+ "end_time": "2026-04-01T11:08:38.332350Z",
+ "start_time": "2026-04-01T11:08:38.263473Z"
}
},
+ "source": [
+ "m6.solve(reformulate_sos=\"auto\")"
+ ],
"outputs": [],
"execution_count": null
},
{
"cell_type": "code",
- "source": "m6.solve(reformulate_sos=\"auto\")",
"metadata": {
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:28.878112Z",
- "start_time": "2026-03-09T10:17:28.791383Z"
+ "end_time": "2026-04-01T11:08:38.341128Z",
+ "start_time": "2026-04-01T11:08:38.336172Z"
}
},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Set parameter Username\n",
- "Academic license - for non-commercial use only - expires 2026-12-18\n",
- "Read LP format model from file /private/var/folders/7j/18_93__x4wl2px44pq3f570m0000gn/T/linopy-problem-fm9ucuy2.lp\n",
- "Reading time = 0.00 seconds\n",
- "obj: 27 rows, 24 columns, 66 nonzeros\n",
- "Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (mac64[arm] - Darwin 25.2.0 25C56)\n",
- "\n",
- "CPU model: Apple M3\n",
- "Thread count: 8 physical cores, 8 logical processors, using up to 8 threads\n",
- "\n",
- "Optimize a model with 27 rows, 24 columns and 66 nonzeros (Min)\n",
- "Model fingerprint: 0x4b0d5f70\n",
- "Model has 9 linear objective coefficients\n",
- "Variable types: 15 continuous, 9 integer (9 binary)\n",
- "Coefficient statistics:\n",
- " Matrix range [1e+00, 8e+01]\n",
- " Objective range [1e+00, 5e+01]\n",
- " Bounds range [1e+00, 1e+02]\n",
- " RHS range [2e+01, 7e+01]\n",
- "\n",
- "Found heuristic solution: objective 675.0000000\n",
- "Presolve removed 24 rows and 19 columns\n",
- "Presolve time: 0.00s\n",
- "Presolved: 3 rows, 5 columns, 10 nonzeros\n",
- "Found heuristic solution: objective 485.0000000\n",
- "Variable types: 3 continuous, 2 integer (2 binary)\n",
- "\n",
- "Root relaxation: objective 3.516667e+02, 3 iterations, 0.00 seconds (0.00 work units)\n",
- "\n",
- " Nodes | Current Node | Objective Bounds | Work\n",
- " Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time\n",
- "\n",
- " 0 0 351.66667 0 1 485.00000 351.66667 27.5% - 0s\n",
- "* 0 0 0 358.3333333 358.33333 0.00% - 0s\n",
- "\n",
- "Explored 1 nodes (5 simplex iterations) in 0.01 seconds (0.00 work units)\n",
- "Thread count was 8 (of 8 available processors)\n",
- "\n",
- "Solution count 3: 358.333 485 675 \n",
- "\n",
- "Optimal solution found (tolerance 1.00e-04)\n",
- "Best objective 3.583333333333e+02, best bound 3.583333333333e+02, gap 0.0000%\n"
- ]
- },
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "Dual values of MILP couldn't be parsed\n"
- ]
- },
- {
- "data": {
- "text/plain": [
- "('ok', 'optimal')"
- ]
- },
- "execution_count": 47,
- "metadata": {},
- "output_type": "execute_result"
+ "source": [
+ "m6.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()"
+ ],
+ "outputs": [],
+ "execution_count": null
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2026-04-01T11:08:38.440499Z",
+ "start_time": "2026-04-01T11:08:38.353813Z"
}
+ },
+ "source": [
+ "bp6 = linopy.breakpoints({\"power\": x_pts6.values, \"fuel\": y_pts6.values}, dim=\"var\")\n",
+ "plot_pwl_results(m6, bp6, demand6, color=\"C2\")"
],
+ "outputs": [],
"execution_count": null
},
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "A",
+ "t",
+ " ",
+ "*",
+ "*",
+ "t",
+ "=",
+ "1",
+ "*",
+ "*",
+ ",",
+ " ",
+ "d",
+ "e",
+ "m",
+ "a",
+ "n",
+ "d",
+ " ",
+ "(",
+ "1",
+ "5",
+ " ",
+ "M",
+ "W",
+ ")",
+ " ",
+ "i",
+ "s",
+ " ",
+ "b",
+ "e",
+ "l",
+ "o",
+ "w",
+ " ",
+ "t",
+ "h",
+ "e",
+ " ",
+ "m",
+ "i",
+ "n",
+ "i",
+ "m",
+ "u",
+ "m",
+ " ",
+ "l",
+ "o",
+ "a",
+ "d",
+ " ",
+ "(",
+ "3",
+ "0",
+ " ",
+ "M",
+ "W",
+ ")",
+ ".",
+ " ",
+ "T",
+ "h",
+ "e",
+ " ",
+ "s",
+ "o",
+ "l",
+ "v",
+ "e",
+ "r",
+ "\n",
+ "k",
+ "e",
+ "e",
+ "p",
+ "s",
+ " ",
+ "t",
+ "h",
+ "e",
+ " ",
+ "u",
+ "n",
+ "i",
+ "t",
+ " ",
+ "o",
+ "f",
+ "f",
+ " ",
+ "(",
+ "`",
+ "c",
+ "o",
+ "m",
+ "m",
+ "i",
+ "t",
+ "=",
+ "0",
+ "`",
+ ")",
+ ",",
+ " ",
+ "s",
+ "o",
+ " ",
+ "`",
+ "p",
+ "o",
+ "w",
+ "e",
+ "r",
+ "=",
+ "0",
+ "`",
+ " ",
+ "a",
+ "n",
+ "d",
+ " ",
+ "`",
+ "f",
+ "u",
+ "e",
+ "l",
+ "=",
+ "0",
+ "`",
+ " ",
+ "—",
+ " ",
+ "t",
+ "h",
+ "e",
+ " ",
+ "`",
+ "a",
+ "c",
+ "t",
+ "i",
+ "v",
+ "e",
+ "`",
+ "\n",
+ "p",
+ "a",
+ "r",
+ "a",
+ "m",
+ "e",
+ "t",
+ "e",
+ "r",
+ " ",
+ "e",
+ "n",
+ "f",
+ "o",
+ "r",
+ "c",
+ "e",
+ "s",
+ " ",
+ "t",
+ "h",
+ "i",
+ "s",
+ ".",
+ " ",
+ "D",
+ "e",
+ "m",
+ "a",
+ "n",
+ "d",
+ " ",
+ "i",
+ "s",
+ " ",
+ "m",
+ "e",
+ "t",
+ " ",
+ "b",
+ "y",
+ " ",
+ "t",
+ "h",
+ "e",
+ " ",
+ "b",
+ "a",
+ "c",
+ "k",
+ "u",
+ "p",
+ " ",
+ "s",
+ "o",
+ "u",
+ "r",
+ "c",
+ "e",
+ ".",
+ "\n",
+ "\n",
+ "A",
+ "t",
+ " ",
+ "*",
+ "*",
+ "t",
+ "=",
+ "2",
+ "*",
+ "*",
+ " ",
+ "a",
+ "n",
+ "d",
+ " ",
+ "*",
+ "*",
+ "t",
+ "=",
+ "3",
+ "*",
+ "*",
+ ",",
+ " ",
+ "t",
+ "h",
+ "e",
+ " ",
+ "u",
+ "n",
+ "i",
+ "t",
+ " ",
+ "c",
+ "o",
+ "m",
+ "m",
+ "i",
+ "t",
+ "s",
+ " ",
+ "a",
+ "n",
+ "d",
+ " ",
+ "o",
+ "p",
+ "e",
+ "r",
+ "a",
+ "t",
+ "e",
+ "s",
+ " ",
+ "o",
+ "n",
+ " ",
+ "t",
+ "h",
+ "e",
+ " ",
+ "P",
+ "W",
+ "L",
+ " ",
+ "c",
+ "u",
+ "r",
+ "v",
+ "e",
+ "."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "#",
+ "#",
+ " ",
+ "7",
+ ".",
+ " ",
+ "N",
+ "-",
+ "v",
+ "a",
+ "r",
+ "i",
+ "a",
+ "b",
+ "l",
+ "e",
+ " ",
+ "f",
+ "o",
+ "r",
+ "m",
+ "u",
+ "l",
+ "a",
+ "t",
+ "i",
+ "o",
+ "n",
+ " ",
+ "-",
+ "-",
+ " ",
+ "C",
+ "H",
+ "P",
+ " ",
+ "p",
+ "l",
+ "a",
+ "n",
+ "t",
+ "\n",
+ "\n",
+ "W",
+ "h",
+ "e",
+ "n",
+ " ",
+ "m",
+ "u",
+ "l",
+ "t",
+ "i",
+ "p",
+ "l",
+ "e",
+ " ",
+ "o",
+ "u",
+ "t",
+ "p",
+ "u",
+ "t",
+ "s",
+ " ",
+ "a",
+ "r",
+ "e",
+ " ",
+ "l",
+ "i",
+ "n",
+ "k",
+ "e",
+ "d",
+ " ",
+ "t",
+ "h",
+ "r",
+ "o",
+ "u",
+ "g",
+ "h",
+ " ",
+ "s",
+ "h",
+ "a",
+ "r",
+ "e",
+ "d",
+ " ",
+ "o",
+ "p",
+ "e",
+ "r",
+ "a",
+ "t",
+ "i",
+ "n",
+ "g",
+ " ",
+ "p",
+ "o",
+ "i",
+ "n",
+ "t",
+ "s",
+ " ",
+ "(",
+ "e",
+ ".",
+ "g",
+ ".",
+ ",",
+ " ",
+ "a",
+ "\n",
+ "c",
+ "o",
+ "m",
+ "b",
+ "i",
+ "n",
+ "e",
+ "d",
+ " ",
+ "h",
+ "e",
+ "a",
+ "t",
+ " ",
+ "a",
+ "n",
+ "d",
+ " ",
+ "p",
+ "o",
+ "w",
+ "e",
+ "r",
+ " ",
+ "p",
+ "l",
+ "a",
+ "n",
+ "t",
+ " ",
+ "w",
+ "h",
+ "e",
+ "r",
+ "e",
+ " ",
+ "p",
+ "o",
+ "w",
+ "e",
+ "r",
+ ",",
+ " ",
+ "f",
+ "u",
+ "e",
+ "l",
+ ",",
+ " ",
+ "a",
+ "n",
+ "d",
+ " ",
+ "h",
+ "e",
+ "a",
+ "t",
+ " ",
+ "a",
+ "r",
+ "e",
+ " ",
+ "a",
+ "l",
+ "l",
+ " ",
+ "f",
+ "u",
+ "n",
+ "c",
+ "t",
+ "i",
+ "o",
+ "n",
+ "s",
+ "\n",
+ "o",
+ "f",
+ " ",
+ "a",
+ " ",
+ "s",
+ "i",
+ "n",
+ "g",
+ "l",
+ "e",
+ " ",
+ "l",
+ "o",
+ "a",
+ "d",
+ "i",
+ "n",
+ "g",
+ " ",
+ "p",
+ "a",
+ "r",
+ "a",
+ "m",
+ "e",
+ "t",
+ "e",
+ "r",
+ ")",
+ ",",
+ " ",
+ "u",
+ "s",
+ "e",
+ " ",
+ "t",
+ "h",
+ "e",
+ " ",
+ "*",
+ "*",
+ "N",
+ "-",
+ "v",
+ "a",
+ "r",
+ "i",
+ "a",
+ "b",
+ "l",
+ "e",
+ "*",
+ "*",
+ " ",
+ "A",
+ "P",
+ "I",
+ ".",
+ "\n",
+ "\n",
+ "I",
+ "n",
+ "s",
+ "t",
+ "e",
+ "a",
+ "d",
+ " ",
+ "o",
+ "f",
+ " ",
+ "s",
+ "e",
+ "p",
+ "a",
+ "r",
+ "a",
+ "t",
+ "e",
+ " ",
+ "x",
+ "/",
+ "y",
+ " ",
+ "b",
+ "r",
+ "e",
+ "a",
+ "k",
+ "p",
+ "o",
+ "i",
+ "n",
+ "t",
+ "s",
+ ",",
+ " ",
+ "y",
+ "o",
+ "u",
+ " ",
+ "p",
+ "a",
+ "s",
+ "s",
+ " ",
+ "a",
+ " ",
+ "d",
+ "i",
+ "c",
+ "t",
+ "i",
+ "o",
+ "n",
+ "a",
+ "r",
+ "y",
+ " ",
+ "o",
+ "f",
+ " ",
+ "e",
+ "x",
+ "p",
+ "r",
+ "e",
+ "s",
+ "s",
+ "i",
+ "o",
+ "n",
+ "s",
+ "\n",
+ "a",
+ "n",
+ "d",
+ " ",
+ "a",
+ " ",
+ "s",
+ "i",
+ "n",
+ "g",
+ "l",
+ "e",
+ " ",
+ "b",
+ "r",
+ "e",
+ "a",
+ "k",
+ "p",
+ "o",
+ "i",
+ "n",
+ "t",
+ " ",
+ "D",
+ "a",
+ "t",
+ "a",
+ "A",
+ "r",
+ "r",
+ "a",
+ "y",
+ " ",
+ "w",
+ "h",
+ "o",
+ "s",
+ "e",
+ " ",
+ "c",
+ "o",
+ "o",
+ "r",
+ "d",
+ "i",
+ "n",
+ "a",
+ "t",
+ "e",
+ "s",
+ " ",
+ "m",
+ "a",
+ "t",
+ "c",
+ "h",
+ " ",
+ "t",
+ "h",
+ "e",
+ " ",
+ "d",
+ "i",
+ "c",
+ "t",
+ "i",
+ "o",
+ "n",
+ "a",
+ "r",
+ "y",
+ " ",
+ "k",
+ "e",
+ "y",
+ "s",
+ "."
+ ]
+ },
{
"cell_type": "code",
- "source": "m6.solution[[\"commit\", \"power\", \"fuel\", \"backup\"]].to_pandas()",
"metadata": {
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:29.079925Z",
- "start_time": "2026-03-09T10:17:29.069821Z"
+ "end_time": "2026-04-01T11:08:38.453545Z",
+ "start_time": "2026-04-01T11:08:38.450339Z"
}
},
- "outputs": [
- {
- "data": {
- "text/plain": [
- " commit power fuel backup\n",
- "time \n",
- "1 0.0 0.0 0.000000 15.0\n",
- "2 1.0 70.0 110.000000 0.0\n",
- "3 1.0 50.0 73.333333 0.0"
- ],
- "text/html": [
- "
\n",
- "\n",
- "
\n",
- " \n",
- " \n",
- " | \n",
- " commit | \n",
- " power | \n",
- " fuel | \n",
- " backup | \n",
- "
\n",
- " \n",
- " | time | \n",
- " | \n",
- " | \n",
- " | \n",
- " | \n",
- "
\n",
- " \n",
- " \n",
- " \n",
- " | 1 | \n",
- " 0.0 | \n",
- " 0.0 | \n",
- " 0.000000 | \n",
- " 15.0 | \n",
- "
\n",
- " \n",
- " | 2 | \n",
- " 1.0 | \n",
- " 70.0 | \n",
- " 110.000000 | \n",
- " 0.0 | \n",
- "
\n",
- " \n",
- " | 3 | \n",
- " 1.0 | \n",
- " 50.0 | \n",
- " 73.333333 | \n",
- " 0.0 | \n",
- "
\n",
- " \n",
- "
\n",
- "
"
- ]
- },
- "execution_count": 48,
- "metadata": {},
- "output_type": "execute_result"
+ "source": [
+ "# CHP operating points: as load increases, power, fuel, and heat all change\n",
+ "bp_chp = linopy.breakpoints(\n",
+ " {\n",
+ " \"power\": [0, 30, 60, 100],\n",
+ " \"fuel\": [0, 40, 85, 160],\n",
+ " \"heat\": [0, 25, 55, 95],\n",
+ " },\n",
+ " dim=\"var\",\n",
+ ")\n",
+ "print(\"CHP breakpoints:\")\n",
+ "print(bp_chp.to_pandas())"
+ ],
+ "outputs": [],
+ "execution_count": null
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2026-04-01T11:08:38.518849Z",
+ "start_time": "2026-04-01T11:08:38.466354Z"
+ }
+ },
+ "source": [
+ "m7 = linopy.Model()\n",
+ "\n",
+ "power = m7.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n",
+ "fuel = m7.add_variables(name=\"fuel\", lower=0, coords=[time])\n",
+ "heat = m7.add_variables(name=\"heat\", lower=0, coords=[time])\n",
+ "\n",
+ "# N-variable: all three linked through shared interpolation weights\n",
+ "m7.add_piecewise_constraints(\n",
+ " (power, bp_chp.sel(var=\"power\")),\n",
+ " (fuel, bp_chp.sel(var=\"fuel\")),\n",
+ " (heat, bp_chp.sel(var=\"heat\")),\n",
+ " name=\"chp\",\n",
+ " method=\"sos2\",\n",
+ ")\n",
+ "\n",
+ "# Fixed power dispatch determines the operating point — fuel and heat follow\n",
+ "power_dispatch = xr.DataArray([20, 60, 90], coords=[time])\n",
+ "m7.add_constraints(power == power_dispatch, name=\"power_dispatch\")\n",
+ "\n",
+ "m7.add_objective(fuel.sum())"
+ ],
+ "outputs": [],
+ "execution_count": null
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2026-04-01T11:08:38.581845Z",
+ "start_time": "2026-04-01T11:08:38.522785Z"
}
+ },
+ "source": [
+ "m7.solve(reformulate_sos=\"auto\")"
],
+ "outputs": [],
"execution_count": null
},
{
"cell_type": "code",
- "source": "plot_pwl_results(m6, x_pts6, y_pts6, demand6, color=\"C2\")",
"metadata": {
"ExecuteTime": {
- "end_time": "2026-03-09T10:17:29.226034Z",
- "start_time": "2026-03-09T10:17:29.097467Z"
+ "end_time": "2026-04-01T11:08:38.632933Z",
+ "start_time": "2026-04-01T11:08:38.620498Z"
}
},
+ "source": [
+ "m7.solution[[\"power\", \"fuel\", \"heat\"]].to_pandas().round(2)"
+ ],
+ "outputs": [],
+ "execution_count": null
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2026-04-01T11:08:38.743684Z",
+ "start_time": "2026-04-01T11:08:38.645091Z"
+ }
+ },
+ "source": [
+ "plot_pwl_results(m7, bp_chp, power_dispatch, x_name=\"fuel\")"
+ ],
+ "outputs": [],
+ "execution_count": null
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": "## 8. Per-entity breakpoints — Fleet of generators\n\nWhen different generators have different efficiency curves, pass\nper-entity breakpoints using a dict with `breakpoints()`. The breakpoint\narrays are auto-broadcast over the remaining dimensions (here `time`)."
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2026-04-01T11:08:38.759346Z",
+ "start_time": "2026-04-01T11:08:38.752115Z"
+ }
+ },
+ "source": [
+ "gens = pd.Index([\"gas\", \"coal\"], name=\"gen\")\n",
+ "\n",
+ "# Each generator has its own heat-rate curve\n",
+ "x_gen = linopy.breakpoints(\n",
+ " {\"gas\": [0, 30, 60, 100], \"coal\": [0, 50, 100, 150]}, dim=\"gen\"\n",
+ ")\n",
+ "y_gen = linopy.breakpoints(\n",
+ " {\"gas\": [0, 40, 90, 180], \"coal\": [0, 55, 130, 225]}, dim=\"gen\"\n",
+ ")\n",
+ "print(\"Power breakpoints:\\n\", x_gen.to_pandas())\n",
+ "print(\"Fuel breakpoints:\\n\", y_gen.to_pandas())"
+ ],
+ "outputs": [],
+ "execution_count": null
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2026-04-01T11:08:38.852492Z",
+ "start_time": "2026-04-01T11:08:38.765098Z"
+ }
+ },
+ "source": [
+ "m8 = linopy.Model()\n",
+ "\n",
+ "power = m8.add_variables(name=\"power\", lower=0, upper=150, coords=[gens, time])\n",
+ "fuel = m8.add_variables(name=\"fuel\", lower=0, coords=[gens, time])\n",
+ "\n",
+ "# Per-entity breakpoints: each generator gets its own curve\n",
+ "m8.add_piecewise_constraints(\n",
+ " (power, x_gen),\n",
+ " (fuel, y_gen),\n",
+ " name=\"pwl\",\n",
+ ")\n",
+ "\n",
+ "demand8 = xr.DataArray([80, 120, 60], coords=[time])\n",
+ "m8.add_constraints(power.sum(\"gen\") >= demand8, name=\"demand\")\n",
+ "m8.add_objective(fuel.sum())"
+ ],
+ "outputs": [],
+ "execution_count": null
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2026-04-01T11:08:38.923105Z",
+ "start_time": "2026-04-01T11:08:38.855310Z"
+ }
+ },
+ "source": [
+ "m8.solve(reformulate_sos=\"auto\")"
+ ],
+ "outputs": [],
+ "execution_count": null
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2026-04-01T11:08:38.943143Z",
+ "start_time": "2026-04-01T11:08:38.934884Z"
+ }
+ },
+ "source": [
+ "m8.solution[[\"power\", \"fuel\"]].to_dataframe().round(2)"
+ ],
+ "outputs": [],
+ "execution_count": null
+ },
+ {
+ "cell_type": "code",
+ "metadata": {
+ "ExecuteTime": {
+ "end_time": "2026-04-01T11:08:39.047739Z",
+ "start_time": "2026-04-01T11:08:38.949442Z"
+ }
+ },
+ "source": "sol = m8.solution\nfig, axes = plt.subplots(1, 2, figsize=(10, 3.5))\n\nfor i, gen in enumerate(gens):\n ax = axes[i]\n fuel_bp = y_gen.sel(gen=gen).values\n power_bp = x_gen.sel(gen=gen).values\n ax.plot(fuel_bp, power_bp, \"o-\", color=f\"C{i}\", label=\"Breakpoints\")\n for t in time:\n ax.plot(\n float(sol[\"fuel\"].sel(gen=gen, time=t)),\n float(sol[\"power\"].sel(gen=gen, time=t)),\n \"D\",\n color=\"black\",\n ms=8,\n )\n ax.set(xlabel=\"Fuel\", ylabel=\"Power [MW]\", title=f\"{gen.title()} heat-rate curve\")\n ax.legend()\n\nplt.tight_layout()",
"outputs": [
{
"data": {
"text/plain": [
""
],
- "image/png": "iVBORw0KGgoAAAANSUhEUgAAA90AAAFUCAYAAAA57l+/AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/TGe4hAAAACXBIWXMAAA9hAAAPYQGoP6dpAABq3ElEQVR4nO3dB3hU1fbw4ZVeKKETeu9SpDeRJoiCIlxRBKliowiIUqQLBlABQYpYKCqCKKCgYqFKbyId6b1Jh0DqfM/afjP/mZBAEjKZZOb33mducs6cmZycieyz9l57bS+LxWIRAAAAAACQ4rxT/i0BAAAAAABBNwAAAAAATsRINwAAAAAATkLQDQAAAACAkxB0AwAAAADgJATdAAAAAAA4CUE3AAAAAABOQtANAAAAAICTEHQDAAAAAOAkBN0AAABAGjJ8+HDx8vKS9E5/hx49erj6NACXI+gGnGTWrFmmsdm6dWu8z9evX18eeughp17/n3/+2TTcqWnq1KnmdwcAAI73BNZHYGCg5M2bV5o2bSqTJk2SGzdupMlL5Yr7CMAdEXQDbkwbyxEjRqTqzyToBgAgfiNHjpQvv/xSpk2bJj179jT7evfuLeXLl5edO3fajhs8eLDcvn3bI+8jAHfk6+oTAJB2WSwWuXPnjgQFBYm709/T399fvL3piwQAOEezZs2katWqtu2BAwfKihUrpHnz5vLUU0/Jvn37TJvr6+trHgDcA3eXQBrz1VdfSZUqVUyjmy1bNnn++efl5MmTDsf8+eef8uyzz0rBggUlICBAChQoIH369HHoFe/UqZNMmTLFfG+f0nYvhQsXNg3/r7/+am4K9Bw++eQT89zMmTOlYcOGkitXLvMzy5Yta3rq475+z549snr1atvP0zR6q6tXr5oefT1ffY/ixYvL2LFjJTY2NlHX5pdffpFHH31UMmXKJJkzZ5Zq1arJ3LlzHX6+/t5x6TnYn8eqVavMuc2bN8+MJuTLl0+Cg4Nl+/btZv/s2bPveg+9Jvrc0qVLbftOnz4tXbp0kdy5c5vfp1y5cvLFF18k6ncBAEBp2zpkyBA5fvy4uQdIaE7377//LnXr1pUsWbJIxowZpVSpUjJo0KC72rb58+eb/aGhoZIhQwYTzDvjPkLb7o8++siM0mu6fM6cOeXxxx+Pd1rd4sWLzZQ6a1u5bNkyPnx4FLrQACe7du2a/Pvvv3ftj4qKumvf6NGjTcPbpk0beemll+TixYsyefJkqVevnvz111+moVULFiyQ8PBwee211yR79uyyefNmc9ypU6fMc+qVV16RM2fOmEZaU9kS68CBA9K2bVvz+m7duplGXWmArQ2lNt7a+75kyRJ5/fXXTaPbvXt3c8zEiRNNupzeDLzzzjtmnwakSs9XA2YNVPW9taFfv3696eU/e/asee395sNpgKvnoK/Ra6HXRBvuF154QZLj3XffNaPb/fr1k4iICNORULRoUfn222+lY8eODsfqTUzWrFnN/Dt1/vx5qVmzpq1IjN5saKdA165d5fr166ZzAQCAxHjxxRdNoPzbb7+Ztjcu7dDWTvEKFSqYFHUNXg8dOiTr1q2L915C26b+/fvLhQsXTPvauHFj2bFjhy1zLSXuI7S907ZZR+/1niU6OtoE8xs3bnQYzV+7dq0sXLjQ3DNop7nOYW/durWcOHHC/GzAI1gAOMXMmTMt+p/YvR7lypWzHX/s2DGLj4+PZfTo0Q7vs2vXLouvr6/D/vDw8Lt+XlhYmMXLy8ty/Phx277u3bubn5NYhQoVMscvW7bsrufi+5lNmza1FC1a1GGf/k6PPvroXce+++67lgwZMlj++ecfh/0DBgwwv/eJEycSPK+rV69aMmXKZKlRo4bl9u3bDs/FxsY6nH/Hjh3ver2ej/05rVy50vyeeu5xf6+BAwda/Pz8LJcvX7bti4iIsGTJksXSpUsX276uXbta8uTJY/n3338dXv/8889bQkJC4r1eAADPvifYsmVLgsdo2/Hwww+b74cNG+bQfk+YMMFsX7x4McHXW9u2fPnyWa5fv27b/+2335r9H330UYrdR6xYscLs79Wr113P2bfLeoy/v7/l0KFDtn1///232T958uQEfxfA3ZBeDjiZpmZpL3Hch/ZW29NeYB011lFuHRm3PjQ9rESJErJy5UrbsfZzrG/dumWOq127tpmDraO/D6JIkSK20Vx79j/TOnqvI9dHjhwx2/ejPeePPPKIGS22//209z0mJkbWrFmT4Gv1emll1wEDBpgUNnsPsqSKjmbHna/+3HPPmSwE/TysdORBU+P1OaXX+fvvv5cWLVqY7+1/H712ej00VR0AgMTSLLGEqphbM91++OGH+07J6tChgxlRtvrf//4nefLkMUXRUuo+QttAbX+HDRt213Nx22Vt54sVK2bb1vsfnSKm9w+ApyC9HHCy6tWrO6RZWVmDT6uDBw+axk4D7Pj4+fnZvteUrKFDh8qPP/4oV65ccTguMQHw/YLu+GgKmzauGzZsMClpcX9mSEjIPd9Xfz+tzKpp2PHRFLiEHD582HxN6SXW4vtdK1asKKVLlzbp5Jo6p/T7HDlymHl3StP+NQifMWOGeST19wEAIK6bN2+auinx0U7fzz77zKRxawd0o0aNpFWrViagjlsANO59hAbBWkPl2LFjKXYfoe2yLnmmtWfuR6eTxXcPFPfnAu6MoBtII7TnWhtGnRfs4+MTbw+40lHhxx57TC5fvmzma2mAqIVSdK60Fj1JbFGyhMRXqVwbV23g9WeNHz/eFFzRudDaaz5hwoRE/Uw9Rs/77bffjvf5kiVLyoNKaNRbr1l81zShqux6c6Nz4rRTREcL9KZE57lbK8laf9/27dvfNffbKm4mAwAACdG51BrsanAcH22vNCNMs95++uknU89EO4S1M1izseJr4xLi7PuIuBI6t/+yzwHPQNANpBGaeqUNkI6+3isA3bVrl/zzzz+mwramkNmnYMf1IKnX9rRomhYa0+DTvsfaPuX9fj9Tfz/txdc0s6SypqXt3r07wRsSa8+5jkDHpRVhtUBaYmnQreuSavqcFoLTwmhaRd5KR+s1GNcbl+T8PgAA2LMWKotvepeVjmhrB7g+tAP8vffeM0VLtS22b4s0s8ye3lto0TVrZ3BK3Edou6yremjgnpjRbsDTMacbSCM0TUx7gzXYi9v7q9uXLl1y6DG2P0a/12U74tKeaxVfIJoU8f1M7ZHXZcTi+5nx/Tydq66p6dpIx6XHa9XThDRp0sQEuWFhYWY9bXv256Q3AVo1NTIy0rZPl/iKu1TK/ZQpU8YsgaKjCPrQuXBaQd7+emjlVQ3KtSMgLk0/BwAgMXSdbl1NQzvd27VrF+8xGtzGValSJfNVO8XtzZkzx2Fu+HfffWdWCdEq49Y27EHvI7QN1NfoPUtcjGADd2OkG0gjNGAcNWqUWQ5L5121bNnSBJpHjx6VRYsWycsvv2yWttI0MD1Wv9dUMC1GosFffHOjdL1v1atXL9N7rg2t/YhtYmnQq+nkWjhMlxDREetPP/3UzD3Thjzuz9TlxfR30VFpPUbT39566y0zUq5Lnmj6mh6nxVu0x11vCPR31nnT8dHfUdPYdS6brs2tS4TpqPbff/9t5pdb19XW5/W9dJ1QDfI1LV7XPLUv4JKU0W6d76aF23Rud9w5c2PGjDGjCzVq1DDLu+hyY3pTpAXU/vjjj3hvkAAAnk2nkO3fv990NOvSkxpw6whzoUKFTBsZt1iolS4TpunlTz75pDlW64ZMnTpV8ufPb9butqcjz7qvc+fO5mfokmHaHluXIkuJ+4gGDRqYZc50+S8dWdd2V9PSdckwfU6X0gRgx9Xl0wFPXR5El7CyXzLM6vvvv7fUrVvXLK+lj9KlS5slOw4cOGA7Zu/evZbGjRtbMmbMaMmRI4elW7dutiU49OdaRUdHW3r27GnJmTOnWQbkfv/J65JbTz75ZLzP/fjjj5YKFSpYAgMDLYULF7aMHTvW8sUXX5j3PHr0qO24c+fOmffQJb70Ofulum7cuGGW5CpevLhZQkTPvXbt2pYPPvjAEhkZeZ8r+t856PFBQUGWzJkzW6pXr2755ptvHI758MMPzXIpAQEBljp16li2bt2a4JJhCxYsSPBnHTx40La029q1a+M95vz58+azKVCggFlmLDQ01NKoUSPLjBkz7vu7AAA8dxlRbQO1zXjsscfMUl72S3zFt2TY8uXLLU8//bQlb9685rX6tW3btg7LcFrbNm0Xta3NlSuXaS+1TbZfBiyl7iP0uffff9/cp+g56THNmjWzbNu2zXaMHq/tZFwJLfEJuCsv/T/7IBwAAABA+rJq1SozyqxLdGpVcwBpB3O6AQAAAABwEoJuAAAAAACchKAbAAAAAAAnIegGAABJVrhwYbOGb9xH9+7dzfO6vJ9+nz17dsmYMaNZYkgrKQNwjvr165vlupjPDaQ9FFIDAABJpuvRx8TE2LZ1zfrHHnvMLKWnN/+vvfaa/PTTTzJr1iwJCQkxSwjp0nvr1q3jagMAPApBNwAAeGC9e/eWpUuXmjV7r1+/Ljlz5pS5c+faRt10beIyZcrIhg0bpGbNmlxxAIDH8HX1CaQFsbGxcubMGcmUKZNJjQMAIC3RlNEbN25I3rx5zWhxWhMZGSlfffWV9O3b17Sj27Ztk6ioKGncuLHtmNKlS0vBggXvGXRHRESYh337fPnyZZOiTvsMAEiv7TNBt4gJuAsUKJCanw8AAEl28uRJyZ8/f5q7cosXL5arV69Kp06dzPa5c+fE399fsmTJ4nBc7ty5zXMJCQsLkxEjRjj9fAEASM322aVB95o1a+T99983PeJnz56VRYsWScuWLW3PJ9SrPW7cOHnrrbdshVyOHz9+V6M9YMCARJ+HjnBbL1bmzJmT+dsAAOAcmq6tncPW9iqt+fzzz6VZs2amp/9BDBw40IyWW127ds2MjtM+IyVotoXeb+r9ZWhoaKJfd+H2BT6ANCxXUK5EH6udfjoymSdPHjPlBUit9tmlQfetW7ekYsWK0qVLF2nVqtVdz+s/jPZ++eUX6dq1q6mAam/kyJHSrVs323ZSb0qswb0G3ATdAIC0Ki2mWGvH9x9//CELFy607dOARlPOdfTbfrRbq5ffK9gJCAgwj7hon5ESrKmf2jl06tSpRL+u/OzyfABp2K6OuxJ9rI5Enj592vwtcM+P1GyfXRp0a6+4PhISt2H+4YcfpEGDBlK0aFGH/RpkJ6XHEgAApIyZM2dKrly55Mknn7Ttq1Klivj5+cny5cttHeUHDhyQEydOSK1atbj0AACPkvaqsSRAe8d16REd6Y5rzJgxpsjKww8/bNLVo6Oj7/leWqRFUwHsHwAAIGm00JkG3R07dhRf3//rx9clwrS91lRxXUJMp5F17tzZBNxULgcAeJp0U0ht9uzZZkQ7bhp6r169pHLlypItWzZZv369mQ+maenjx49P8L0o1AIAwIPTtHIdvdZpYnFNmDDBpHDqSLd2djdt2lSmTp3KZQcAeJx0E3R/8cUX0q5dOwkMDHTYb19wpUKFCqZa6iuvvGIC6/jmhcVXqMU6Af5+vfk6Pw2eTdMlfXx8XH0aAJAmNGnSxBQlio+211OmTDEPZ4uJiTFLlCHtov0E4MnSRdD9559/mrlg8+fPv++xNWrUMOnlx44dk1KlSiWpUEtCNNg+evSoCbwBLQqkNQTSYkEjAGlDTGyMbL+wXS6GX5ScwTmlcq7K4uNNh11K04BfqxFrwTakfbSfADxVugi6dSkSLcqilc7vZ8eOHSadTYu6pFSDrunqOrqpo+H3WvQc7k3/FsLDw+XChf+WDtHlJgAgrj+O/yFjNo+R8+HnbftyB+eWAdUHSONCjblgKcgacGubHxwcTGdoGkX7CcDTuTTovnnzphw6dMi2raPJGjTr/Gxdl9Oa+r1gwQL58MMP73r9hg0bZNOmTaaiuc731u0+ffpI+/btJWvWrClyjjpqroGWLi+hDTo8W1BQkPmqgbfe5JFqDiBuwN13VV+xiGPK9YXwC2b/+PrjCbxTMKXcGnBrMVWkbbSfADyZS4PurVu3moDZyjrPWqugzpo1y3w/b94800Patm3bu16vKeL6/PDhw02RliJFipig236+dko06krnigPK2vmi8wcJugHY2ovYGDPCHTfgVrrPS7xk7Oax0qBAA1LNU4B1Djcd4ukH7ScAT+XSoLt+/foJFmCxevnll80jPlq1fOPGjZIamL8L/hYA3IvO4bZPKY8v8D4Xfs4cVy20GheT9tnjcC8FwFMxQRkAgBSgRdNS8jgAAOAeCLqR4jTdv1KlSqnSY7548WKn/xwAuJ870Xfkt2O/JepCaTVzwB116tRJWrZs6erTAIA0h6A7Fef6bTm3RX4+8rP5qtvObvg0KLU+tMjM448/Ljt37hR3oVXlmzVrlujjtU6ALlcCAClp36V98tzS52T5yeX3PE7ndIcGh5rlw+DZ7NtoXb86d+7c8thjj8kXX3zB8qQA4IYIulOpmm3T75tKl1+7SP8/+5uvuq37nUmDbA1M9bF8+XLx9fWV5s2b37coTXqha2UnZb11AEhJ2nn6+a7P5YWfX5Aj145IjqAc8kqFV0xwrf+zZ93uX70/RdTg0EYfO3ZMfvnlF1NY9o033jDttK6cAgBwHwTdqbR8TNziOtblY5wZeGtAqoGpPjTde8CAAXLy5Em5ePGiaeS1h33+/Pny6KOPSmBgoHz99dfmdZ999pmUKVPG7CtdurRMnTrV4X379+8vJUuWNFVIixYtKkOGDLlnwH748GFzXI8ePUzhPOuIs6aGlyhRwvycpk2bmnOzN23aNClWrJipHF+qVCn58ssvE0wvt/4+CxcuNDcuem66rrsuI6dWrVolnTt3lmvXrtlGFzQNXunvZz0PHW343//+l0KfAAB3debmGen6W1eZuH2iRMdGS6OCjWThUwulx8M9zLJguYJzORyv63SzXBjia6Pz5ctnCsMOGjRIfvjhBxOAW1dw0SXRXnrpJcmZM6dkzpxZGjZsKH///fdd07l0hFyXWs2YMaO8/vrrZuWVcePGmffXJdVGjx7t8LPHjx8v5cuXlwwZMkiBAgXMa3QZVytrO/3rr7+a+wF9X2sngZX+DF0tRo/TbLq33377vsVxAcBTubR6eXqkDcrt6NuJHgUJ2xyW4PIxSpeXqRFaI1EjH0G+Qcmu/KmN6VdffSXFixc3jeOtW7fMfg3EdQ30hx9+2BZ4Dx06VD7++GOz76+//pJu3bqZhlmXclO6Jro2yLp2+a5du8zzuk8b3Lg0nV0D6q5du8qoUaNs+3Xtc70JmDNnjgmqtcF//vnnZd26deb5RYsWmR7/iRMnSuPGjWXp0qUmaM6fP7/DMnNxvfPOO/LBBx+YIFq/16XmdC342rVrm/fS3+3AgQPmWL2J0GXrevXqZQJ6Peby5cvy559/JusaA/CMNmDpkaXy3qb35GbUTQn2DZYB1QdIy+Itbf8+Ny7U2CwLplXKtWiazuHWlPLE/DsPz6ZBtXYYaweyBtvPPvusWd9aA/GQkBD55JNPpFGjRvLPP/9ItmzZbB3b+vyyZcvM99pxfOTIEdM5vnr1alm/fr106dLFtKU1atQwr/H29pZJkyaZpVb1WG2DtQ2372TXdlrbU20f9fj27dtLv379bB30eu+g9wIa8GtgrtvaduvvAABwRNCdRBpw15j7X6OVEnQEvPa82ok6dtMLmyTY7781ohNDA1UNLJUG2Xny5DH7tPG06t27t7Rq1cq2PWzYMNNwWvdpg7x3717T0FuD7sGDB9uOL1y4sGmEdb30uEG3NvSaJqfB75tvvunwnI6Ma2BvvQGYPXu2abQ3b94s1atXNw29znnTGwGlvem6PJzuv1fQrefy5JNPmu9HjBgh5cqVM0G3jtjrDYveFGvPv9WJEydMh4Kep3YcFCpUyHQ2AEBc1yKuyaiNo2TZsWVmu2LOihJWN0wKZC5w17EaYLMsWOqrWrWqnDt3LtV/rrYr2ombErS90g7rtWvXmjbxwoULtqlU2gZqhtd3331nW041NjbWBL7ahpUtW9a0kdq5/PPPP5v2XjPFxo4dKytXrrS1udr227fj2in+6quvOgTd2k5Pnz7dZJwpzVYbOXKk7XntyB44cKDtfkGP1ZFxAMDdCLrdmDa8mqKtrly5YhpTLTymjbj9DYqVBubaS66j0jp6baVzyzRgtdKUdO0h12N1BF2f17Q3exrMalEYHc22b9ytdH55tWrVHG4yNEVt3759JujWr3HXZ69Tp4589NFH9/ydK1SoYPteOxmU3rDo+8dHz1EDbU1/19Q5fTzzzDMmPR0ArDad3STvrH3HdJT6ePnIqxVflZfKvyS+3jSjaYkG3KdPn5b0nk2hHcSaRq5trGan2bt9+7Zpf+2DZg24rXSalI+Pj0MHu+7TttDqjz/+kLCwMNm/f79cv37dtON37twxo9vW9k+/WgNua5tqfQ+dqqWp5tYg3tqu6z0FKeYAcDfuFpJIU7x1xDkxtp3fJq8v/2+k9l6mNpoqVXJXSdTPTgodwdV0ciudq63B86effmrS1qzHWFnnc+nz9g2p0gZc6Rzpdu3amVFkTRvX99NRbh0dt6fzzzT9/JtvvjFpbXGDcmfRKrBW1lRPHQVIiN6obN++3cz5/u2330z6uc6R27JlC5XOAUhETIRM2j5J5uydY65GocyFzOh2+ZzluTppkH0mU3r9udrprFlm2iZroKvtU1z2K3HYt3vKWhE97j5rW6g1UDS767XXXjMd45qmrqPq2uEeGRlpC7rjew8CagBIHoLuJNJGJ7Ep3rXz1jbFc7RoWnzzurWarT6vx6XGXD89d+351l7y+GhPuAbKOr9LA+v4aMq4jgxryrjV8ePH7zpO56BpKvsTTzxhgnMNaO174rVXXVPxdFRbaSqcFozRFHOlX3V+tzWlXem2ps4ll84d18IvcWnvvM5104em1+vNzIoVKxzS7gF4nn+u/CMD/hwgB68cNNttSraRN6u+maRpPkhdKZXi7Sra9mitlD59+pgaJjpyr22UjmanlG3btpkAXDvLraPh3377bZLeQzvctUNg06ZNUq9ePVu7ru+tReEAAI4Iup1IA2ktsKNVyjXAtg+8U2P5mIiICNvcNk0v1znU2nPeokWLBF+jI9haWEwbVE211vfQmxh9vc6r1gJlmjquo9uaHv7TTz+Zwinx0VF0fV5T2vWhRV6sc8y1B71nz54mTV1vKHSuWM2aNW1B+FtvvSVt2rQx86s1GF6yZIkpLKMpccmlNy36++vyaVqoRnvz9QZHOxn0piFr1qxmDpzejOgcOACeKdYSK1/u/VI+2v6RRMVGSbbAbDKi9gipX6C+q08NbsTaRmtn8Pnz500bqSnfOgrdoUMHExDXqlVLWrZsaSqRa2G0M2fOmHZVp0HZTw9LCs2A0/nakydPNvcD2qGt87GTSoudjhkzxtwX6BQurYiunecAgLuxZJiTaRVbVy0fow249kTrQ9PFNWV6wYIFUr9+wjeOmnauaegzZ840y4nocmJanVRT3dRTTz1leuA1SNZlSnTkW5cMS4gG2VpVVVPStMCZtWq6Bry69NgLL7xg5mrrcTpX3EpvMnT+thaN0WJoWshNz+le534/Wp1cC8U899xzJv1db2J0VFuDea22qqPreuOhKfH6MwF4nnO3zsnLv70sH2z9wATcj+Z/VL5/6nsCbjitjdYOYe3k1kJn2hGty4bplC7NTtOOYO0U1tU7NOjWVT40u0wz05JLO501QNbiag899JCpRq7BflJpgdQXX3zRZKRp54Bms2lnAADgbl4WJuiYIiI6squFQeLOPdbCIkePHjVBpy6plVy6fBjLx/xHg3gtrpZee8RT6m8CQNqiVclHbhgpNyJvmBoa/ar2k2dLPpvspRpTq51yZ6nRPiP1uPoz05R9LbSna6OfOnUq0a8rP5saDmnZro67nP43ADxo+0x6eSph+RgASJs0yA7bFCZLjiwx2w9lf0jCHgmTwiEpN48WAAB4LoJuAIDH0lUmBv05SM7cOiPeXt7SrXw3eaXiK+Ln7Vi5GQAAILmY041U16lTp3SbWg7APUTFRMnEbROl87LOJuDOnzG/zH58tvR4uAcBNwAASFGMdAMAPMqRq0fMUmD7Lu8z288Uf8asJJHBL4OrTw0AALghgm4AgEfQuqHf7P9Gxm8bLxExEZIlIIsMqzXMqatIAAAAEHQDANzexfCLMmT9EFl3ep3ZrpO3jrxb513JGZzT1acGAADcHEE3AMCtLT++XIZvGC5XI65KgE+A9K3SV9qWbpsmlgIDAADuj6DbCU5fvS1XbkUm+XVZM/hLvixBzjglAPA4t6JuydjNY2XRoUVmu3S20jLmkTFSLEsxV5+aW9C1bvv37y+//PKLhIeHS/HixWXmzJlStWpVWzr/sGHD5NNPPzXFM+vUqSPTpk2TEiVKuPrUAQBIVQTdTgi4G36wSiKiY5P82gBfb1nRrz6BNwA8oB0XdsjAPwfKqZunxEu8pMtDXaR7pe7i58NSYCnhypUrJohu0KCBCbpz5swpBw8elKxZs9qOGTdunEyaNElmz54tRYoUkSFDhkjTpk1l7969EhgYmCLnAQBAekDQncJ0hDs5AbfS1+nrGe0GgOSJio2ST/7+RD7d9anEWmIlT4Y88l7d96Rq6H+jr0gZY8eOlQIFCpiRbSsNrK10lHvixIkyePBgefrpp82+OXPmSO7cuWXx4sXy/PPP81EAADyGS4PuNWvWyPvvvy/btm2Ts2fPyqJFi6Rly5YO6zlrD7k97SVftmyZbfvy5cvSs2dPWbJkiXh7e0vr1q3lo48+kowZM4onq1+/vlSqVMnc9CTHnj17ZOjQoeazOX78uEyYMEF69+6d4ucJACnl2LVjZnR796XdZrt50eYyqMYgyeSfiYucwn788UfTHj/77LOyevVqyZcvn7z++uvSrVs38/zRo0fl3Llz0rjx/1WGDwkJkRo1asiGDRucGnSXn11eUtOujruS/Br7+xs/Pz8pWLCgdOjQQQYNGiS+voyHAIC78XblD79165ZUrFhRpkyZkuAxjz/+uAnIrY9vvvnG4fl27dqZAPH333+XpUuXmkD+5ZdfToWzd286P69o0aIyZswYCQ0NdfXpAECCdFR1wT8LpM3SNibg1iD7/XrvS9gjYQTcTnLkyBHb/Oxff/1VXnvtNenVq5ctkNSAW+nItj3dtj4Xn4iICLl+/brDw11Z7280Lf/NN9+U4cOHm4EIV4uMTHpNGgBAGg66mzVrJqNGjZJnnnkmwWMCAgJM0Gd92M8X27dvnxn1/uyzz0zved26dWXy5Mkyb948OXPmjHgq7UHXkQcd8dfqvPo4duxYkt6jWrVqpvHX0Qj9DAAgLbp0+5L0WtFLRm4YKbejb0uN0Bqy8KmF8niRx119am4tNjZWKleuLO+99548/PDDprNbR7mnT5/+QO8bFhZmRsStD01hd1fW+5tChQqZTgvNCtAMAp0vr6Peer8THBxs7pU0MLd2MOn8+e+++872PprVlidPHtv22rVrzXtr57nSInYvvfSSeV3mzJmlYcOG8vfff9uO12Bf30PvpXSKAPPtAcDNgu7EWLVqleTKlUtKlSplGqVLly7ZntMUtSxZstgqpSpttDTNfNOmTR7bk67Bdq1atcwNkDVDQG9cNOX+Xo9XX33V1acOAIm2+uRqafVjK1l1apX4efvJW1XfkhlNZkhoBrJznE2DvLJlyzrsK1OmjJw4ccJ8b82QOn/+vMMxun2v7KmBAwfKtWvXbI+TJ0+KpwgKCjKjzNpxvnXrVhOA632OBtpPPPGEREVFmU70evXqmXsjpQG6DkDcvn1b9u/fb/Zpp7t2nGvArnQKwIULF0zBO50ypp0ljRo1MtPzrA4dOiTff/+9LFy4UHbs2OGiKwAA7ss3radetWrVyvS8Hj582Mx10h5fbYR8fHxMipoG5PZ0LlS2bNnumb6mPekjRowQd6WjA/7+/qbBtb+5uV9Dqj3gAJDWhUeFywdbPzAp5apE1hISVjdMSmUr5epT8xhaufzAgQMO+/755x8zaqu03db2Z/ny5WYUVWkHt3aIawd6QnSE1tOyqzSo1uukafp6j6OF5tatWye1a9c2z3/99dem41z3awCtNVs++eQT85xOqdNMA73WGoiXLl3afH300Udto96bN282Qbf1un7wwQfmvXS03DodT4N9LXSno+EAAA8Luu0LrZQvX14qVKggxYoVMw2K9tIml/ak9+3b17atNwLunMJmpWuoAkB6tvvf3TLgzwFy/Ppxs92hbAfpVbmXBPh4VqDman369DFBoaaXt2nTxgR2M2bMMA+lI7JafFOnkOm8b+uSYXnz5nUomOrJtA6NZpnpCLam67/wwgtmoEH365Q5q+zZs5tsPx3RVhpQv/HGG3Lx4kUzqq1BuDXo7tq1q6xfv17efvttc6ymkd+8edO8hz0dGdfBDCvtLCHgBgAPDbrj0sJeOXLkMGlQGnRrI6O9t/aio6NNytS90tc8sSdd3a+ie/v27R94Ph4AOEN0bLR8vutzmf73dIm2REuu4Fwyuu5oqZmnJhfcBTR9WVcc0U7skSNHmqBaV8vQ4qZWGvhpwVQdTdV5xVp3ReuwMGf4P7rGuRaj08w07YzQTD1NKb8fHYTQjD4NuPUxevRoc8+jy7ht2bLFBPHWUXINuHUqgDUd3Z5Oz7PKkCFDivxdAADcIOg+deqUmdNtLRii85a1Idc5SlWqVDH7VqxYYXqM7XuJPZE24jExMQ77SC8HkB6dvHFSBv05SHZc/G+KTNPCTWVIzSESEhDi6lPzaM2bNzePhOhotwbk+sDdNNCNm4Gm8+J18EDT8K2Bs973aCq/dQ69XtdHHnlEfvjhB7N6i3Zm6HQyrVejaeda58YaROv8bZ1upwF94cKF+RgAwBODbu2B1VFrK13XUwND7cHVh8671nW3tQdX06C011wbKF0b1No46bxva8VU7d3t0aOHSUvXXmNPpo2rNtpatVxHuPV6JiW9XOd37d271/b96dOnzWej70WaOoDUmuu6+NBiGbN5jIRHh0tGv4xm3W1df1sDD8DdaCr+008/be5rNIDOlCmTDBgwwKyDrvutNKVclxnTANuaxaYF1nT+91tvveVQXFYHKDSlf9y4cVKyZEmzustPP/1kVo6xL0QLAHDT6uVanVMLgOhD6Txr/X7o0KGmUNrOnTvlqaeeMo2EzlPS0ew///zTITVcGxgtHKLp5lrdU3t8rXPKPFm/fv3MNdSecZ2nZa0om1jaKFs/G61+roVX9HtddgQAnO3qnavSd1VfGbp+qAm4q+SuIt8/9b20KNaCgBtubebMmeZ+R7MINGDWzqeff/5Z/Pz8bMfovG7NZtPg20q/j7tPO6f0tRqQd+7c2dxP6cDE8ePH71pDHQDgPF4W/dfcw2khNa34rcuTxK3gfefOHTMCn9i1K3efvibNJ69N9rks7VlXHspHymRaltS/CQBJs+70OhmybohcvH1RfL19pUelHtKpXCfx8fbx2Et5r3bKnaVk+wzXc/Vnlj9/fpO5p5kDOmUxscrPLu/U88KD2dVxl9P/BoAHbZ/T1ZxuAID7uhN9RyZsmyBz988120VDikrYI2FSNrvjetAAAADpCUF3CsuawV8CfL0lIjo2ya/V1+nrAcDT7Lu0zywFduTaEbPdtnRb6VulrwT6MoIJAADSN4LuFJYvS5Cs6FdfrtyKTPJrNeDW1wOAp4iJjZFZe2bJxzs+NsuC5QjKIe/WeVfq5qvr6lMDAABIEQTdTqCBM8EzANzbmZtnZNDaQbLt/Daz3ahgIxlWa5hkDczKpQMAAG6DoBsAkKq0fudPR3+S0RtHy82omxLsGywDqg+QlsVbUpkcAAC4HYJuAECquRZxTUZtHCXLji0z2xVzVpSwumFSIHMBPgUAAOCWCLoBAKli09lN8s7ad+R8+Hnx8fKRVyu+Ki+Vf8ksCwYAAOCuuNNxhqsnRcIvJf11wdlFsjDaA8C9RMZEyqTtk2T23tlmu1DmQmZ0u3xO1r4FAADuj6DbGQH3x1VEoiOS8WkEiPTYRuANwG38c+UfsxTYwSsHzfazJZ+VflX7SbBfsKtPDQAAIFV4p86P8SA6wp2cgFvp65IzQg4AaUysJVbm7JkjbZe2NQF3tsBsMrnhZBlaaygBN5AKChcuLBMnTuRaA0AawEi3m6pfv75UqlQp2Q3up59+KnPmzJHdu3eb7SpVqsh7770n1atXT+EzBeBuzt06J4PXDTZzuNWj+R+V4bWHmzW4AWe7OPnjVL3IOXv2SPJrOnXqJLNn/zfdQmXLlk2qVasm48aNkwoVKqTwGQIAXI2RbsRr1apV0rZtW1m5cqVs2LBBChQoIE2aNJHTp09zxQAkSKuSt/6xtQm4A30CZUjNIWaEm4AbcPT444/L2bNnzWP58uXi6+srzZs35zIBgBsi6HZD2oO+evVq+eijj8yat/o4duxYkt7j66+/ltdff92MlpcuXVo+++wziY2NNTcGABDXjcgbMujPQfLW6rfkeuR1KZe9nHzb4ltpU6oNa28D8QgICJDQ0FDz0LZ2wIABcvLkSbl48aJ5vn///lKyZEkJDg6WokWLypAhQyQqKsrhPZYsWWJGyAMDAyVHjhzyzDPPJHittR3PkiWLace1Y13vDa5evWp7fseOHQ73C7NmzTLHL168WEqUKGF+RtOmTc05AgCShqDbDWmwXatWLenWrZutF11HqjNmzHjPx6uvvprge4aHh5vGXlPgAMDetvPb5H8//k+WHFki3l7e8kqFV+TLJ76UIiFFuFBAIty8eVO++uorKV68uGTPnt3sy5Qpkwl89+7da9p1nfY1YcIE22t++uknE2Q/8cQT8tdff5lgOqEpYJq2rkH9b7/9Jo0aNUr0Z6Jt/+jRo810s3Xr1pkg/fnnn+czBYAkYk63GwoJCRF/f3/TO6496Pa92PeSOXPmBJ/THve8efNK48aNU/RcAaRfUTFRMmXHFPli9xdiEYvky5hPxjwyRirlquTqUwPSvKVLl5oOb3Xr1i3JkyeP2eft/d94yODBgx2KovXr10/mzZsnb7/9ttmnwbAGwCNGjLAdV7FixXjb7y+//NJkwJUrVy5J56id7R9//LHUqFHDbOs89DJlysjmzZup8QIASUDQ7UG0Bz05xowZYxp6TUfT9DIAOHL1iFkKbN/lfeZitCzeUgZUHyAZ/DJwcYBEaNCggUybNs18f+XKFZk6dao0a9bMBLSFChWS+fPny6RJk+Tw4cNmJDw6Otqhc1w70jWj7V4+/PBDE9Bv3brVpKgnlc4z1/R1K51upinn+/btI+gGgCQgvdyDJCe9/IMPPjBBt6akUVEVgMVikW/2fyNtlrYxAXdIQIhMqD9B3q3zLgE3kAQZMmQwneH60MBW51xrgKxp5FrAtF27diZ1XEe/NX38nXfekcjISNvrg4KC7vszHnnkEYmJiZFvv/3WYb91NF3/e7aKO18cAJByGOl2U5perg2tvaSml+scME1f+/XXX6Vq1apOOU8A6cfF8IsyZP0QWXd6ndmuk7eOjKwzUnIF53L1qQHpnhYx02D49u3bsn79ejParYG21fHjxx2O145wncfduXPnBN9T53j36NHDVErXUWtNUVc5c+Y0X7XmS9asWRO8R9DRdR0lt84VP3DggJnXrSnmAIDEI+h2Uzr/a9OmTaYKqY5iawG0pKSXjx07VoYOHSpz584173Xu3Dmz3zoqDsCzLD++XIZvGC5XI65KgE+A9KnSR14o/QKVyYFkioiIsLWtml6uc6c1jbxFixZy/fp1OXHihJnapaPgWjRt0aJFDq8fNmyYKYpWrFgxM7dbA+Sff/7ZzOG2V7t2bbNfU9c18O7du7e5H9ACq8OHDzed6//8849JRY/Lz89PevbsadLc9bUawNesWZPUcgBIItLL3ZT2Zvv4+EjZsmVNj7Y23kmh88w0je1///ufKe5ifWi6OQDPcSvqlgxbP0x6r+ptAu7S2UrL/ObzpV2ZdgTcwANYtmyZrW3VQmVbtmyRBQsWSP369eWpp56SPn36mCBXlxPTkW9dMsyeHqfH//jjj+aYhg0bmvng8albt64J3LU42+TJk00w/c0338j+/fvNiLl2tI8aNequ12lBVg3iX3jhBalTp47pdNe55gCApGGk203p2p46Jyy5krquNwD3s+PCDhn450A5dfOUeImXdH6os/So1EP8fPxcfWpAgnL27JHmr44uBaaPe9EpXvqwp6PU9lq1amUeiWnH69WrZ0bSrTSI3rlzp8Mx9nO8E/MzAACJQ9ANAHAQFRslM3bOMI9YS6zkyZBH3qv7nlQNpbYDAABAUhF0p7Tg7CK+ASLREUl/rb5OXw8ALnL8+nEzur3r311mu3nR5jKoxiDJ5J+JzwQAACC9zeles2aNKRiSN29eMzdw8eLFDktX6Dyi8uXLm2U19JgOHTrImTNnHN5Di3zpa+0fusSVy2QpINJjm8jLq5P+0Nfp6wEglWla6YJ/FsizS541AbcG2ePqjZOwR8IIuBEvLcIVt/3VdZyt7ty5I927d5fs2bObucCtW7eW8+fPczXTiU6dOplK5QCAdD7SretRVqxYUbp06XLXfKHw8HDZvn27KRyix2hlzzfeeMMUF9HlK+yNHDlSunXrZtvOlMnFIzIaOBM8A0gnLt2+JMPXD5dVp1aZ7RqhNWRU3VESmiHU1aeGNK5cuXLyxx9/2La1wrWVFgLT4l1a7CskJMQUBdO2ft26/5acAwDAU7g06NblK/QRH22gf//9d4d9upyGrhWplbgLFizoEGSHhnJzCABJtebUGhmybohcvnNZ/Lz95I3Kb8iLZV8Uby8Wt8D9aZAdX/t77do1+fzzz82yk1pVW82cOdOs77xx40az7BQAAJ4iXc3p1kZc09eyZMnisF/Tyd99910TiOuyFtq7bt/bHt/amPqw0vUwAcCThEeFy4dbP5Rv//nWbBfPUlzGPDJGSmUr5epTQzpy8OBBM/0rMDBQatWqJWFhYaYt3rZtm5km1rhxY9uxmnquz+nKGgkF3clpn2NjY1Pot4Gz8VkhrTh79qzkz5/f1acBF9NO47gZ1OLpQbfODdM53m3btpXMmTPb9vfq1UsqV64s2bJlM+tYDhw40PyHNH78+ATfS28KRowYkUpnDgBpy55/98iAPwfIsev/LSnUoWwH6VW5lwT4BLj61JCO6NrSuuxVqVKlTLur7eojjzwiu3fvlnPnzom/v/9dneS5c+c2z6VE+6zv7+3tbWq95MyZ02xrxzzSZs2IyMhIuXjxovnM9LMCXME6BVU7gE6fPs2HgFSTLoJu7S1v06aN+Ud72rRpDs/17dvX9n2FChXMP+SvvPKKabgDAuK/gdTA3P512pNeoEDKFTA7e/OsXIm4kuTXZQ3IKnky5kmx8wAAe9Gx0fLF7i9k2o5pEm2JllzBuWR03dFSMw+pvkg6++lh2v5qEF6oUCH59ttvJSgoKFmXNCntswZvRYoUMQF/3CKrSJuCg4NNtoN+doAraGas1ou6ceNGkl53PpwikGlZ7uDcyXpdak5P9k0vAffx48dlxYoVDqPc8dFGPzo6Wo4dO2Z63+OjwXhCAXlKBNzNFzeXyJjIJL/W38dflrZcSuANIMWdvHFSBv05SHZc3GG2mxZuKkNqDpGQgBCuNlKEjmqXLFlSDh06JI899pgZ2dTq1/aj3Vq9/F43OUltn7WjXYM4bfdjYmIe+HeA8/j4+Jipf2QjwJX+97//mUdSlZ9d3inng5Sxq+N/y5ymZb7pIeDWOWMrV640y47cz44dO0wPaq5cucQVdIQ7OQG30tfp6xntBpBSNEPoh8M/SNimMAmPDpeMfhnNutu6/jY3v0hJN2/elMOHD8uLL74oVapUET8/P1m+fLlZKkwdOHDAFELVud8pSf+O9WfpAwCAtMjX1Q209ohbHT161ATNOj87T548pidKlw1bunSp6cG2zgPT57V3W4uxbNq0SRo0aGDmaOi2FlFr3769ZM2aVTxZ/fr1pVKlSjJx4sRkvX7hwoXy3nvvmc9HOz9KlCghb775prmZApA+XL1zVUZsGCF/nPhvSafKuSrLe4+8J/ky5nP1qcEN9OvXT1q0aGFSyjW9e9iwYWY0U2uv6AokXbt2Nani2mZrllrPnj1NwE3lcgCAp3Fp0K3V4jRgtrLO4+rYsaMMHz5cfvzxR7OtwaM9HfXWoFJT0ObNm2eO1WqnOrdLg277+WBIHr1Jeuedd0y1We3g0I6Pzp07mwyCpk2bclmBNG796fUyeN1guXj7ovh6+0r3St2lc7nO4uPt4+pTg5s4deqUCbAvXbpkCpnVrVvXLAem36sJEyaYzDMd6dY2WtuOqVOnuvq0AQDwrKBbA2dNfUzIvZ5TWrVcG3g46tSpk6xevdo8PvroI1sWQeHChZP02dh74403ZPbs2bJ27VqCbiANuxN9RyZunyhf7/vabBcJKWKWAiubvayrTw1uRju970WXEZsyZYp5AADgySgf6YY00NYUvm7dupmqrvrQ6q8ZM2a85+PVV19NsPND5+XpfLx69eql+u8DIHH2X94vzy993hZwty3dVuY3n0/ADQAA4EJpupAakkfn0mlKuC7NYV8lVufL30vcyvDXrl2TfPnymbRAnaenaYFakRZA2hITGyOz986WyX9NNsuC5QjKISNrj5RH8j/i6lMDAADweATdHqR48eJJOl6L02mgrgXvdKRb58oXLVr0rtRzAK5z5uYZeWftO7L1/Faz3bBAQxlee7hkDfTsYpIAAABpBUG3B9EU8nvRqu/Tp0+3bWsBHGugrsXs9u3bJ2FhYQTdQBqx9MhSGb1xtNyMuinBvsEyoPoAaVm8JUuBAQAApCEE3W5K08t1mTV7SU0vjys2NtakmgNwrWsR10yw/cuxX8x2xZwVJaxumBTIXICPBgAAII0h6HZTWqlc1zA/duyYGeHWJcCSkl6uI9pVq1aVYsWKmUD7559/li+//FKmTZvm1PMGcG+bz26WQWsHyfnw8+Lj5SOvVnxVXir/klkWDAAAAGkPd2luql+/fma987Jly8rt27eTvGTYrVu35PXXXzfrsAYFBZn1ur/66it57rnnnHregNu7elIk/FKSXxYZmFkmHV4oc/bOEYtYpGCmgmYpsPI5yzvlNAEAAJAyCLrdVMmSJWXDhg3Jfv2oUaPMA0AKB9wfVxGJTsY0DS9v+TV/qFh8feV/Jf8nb1V9S4L9gvl4AAAA0jiCbgBILTrCnZyAW+s0WGKlkE9Geafh+1K/ACsIAAAApBcE3Sksa0BW8ffxl8iYyCS/Vl+nrweA+Lz/6PuSlYAbAAAgXSHoTmF5MuaRpS2XypWIK0l+rQbc+noAiP/fiCxcGAAAgHSGoNsJNHAmeAYAAAAAeHMJEsdisXCpwN8CAAAAgCQh6L4PHx8f8zUyMulztOGewsPDzVc/Pz9XnwoAAACANI708vtdIF9fCQ4OlosXL5ogy9ubfgpPznbQgPvChQuSJUsWW4cMAAAAACSEoPs+vLy8JE+ePHL06FE5fvz4/Q6HB9CAOzQ01NWnAQAAACAdIOhOBH9/fylRogQp5jDZDoxwAwAAAEgsgu5E0rTywMDARF9YAIhr87nNUp3LAgAA4FEIugHAycKjwmXslrGyb/c8+ZarDQAA4FEIugHAif6++LcM/HOgnLxxUspypQEAADwOpbgBwAmiYqNk6o6p0vGXjibgzpMhjwytNYxrDQAA4GEY6QaAFHb8+nEzur3r311m+8miT8qgGoMkc/g1Ed8AkeiIpL+pvi44O58VAABAOkPQDQApuJb79we/l3Fbxsnt6NuSyT+TDKk5RJoVafbfAf6ZRXpsEwm/lPQ314A7SwE+KwAAgHTGpenla9askRYtWkjevHnNetiLFy++6wZ26NChZp3soKAgady4sRw8eNDhmMuXL0u7du0kc+bMZv3krl27ys2bN1P5NwHg6S7dviS9VvaSERtGmIC7emh1WfjUwv8LuK00cM5bKekPAm4AAIB0yaVB961bt6RixYoyZcqUeJ8fN26cTJo0SaZPny6bNm2SDBkySNOmTeXOnTu2YzTg3rNnj/z++++ydOlSE8i//PLLqfhbAPB0a06tkVY/tpJVJ1eJn7ef9KvaTz5t8qmEZgh19akBAADAk4PuZs2ayahRo+SZZ5656zkd5Z44caIMHjxYnn76aalQoYLMmTNHzpw5YxsR37dvnyxbtkw+++wzqVGjhtStW1cmT54s8+bNM8cBgDPpiPaojaOk+/LucvnOZSmepbh88+Q30rFcR/H2ok4lPMeYMWNMxlrv3r1t+7SDvHv37pI9e3bJmDGjtG7dWs6fP+/S8wQAwBXS7F3h0aNH5dy5cyal3CokJMQE1xs2bDDb+lVTyqtWrWo7Ro/39vY2I+MJiYiIkOvXrzs8ACAp9vy7R9osaSPzD8w32y+WfVHmNZ8npbKV4kLCo2zZskU++eQT0zlur0+fPrJkyRJZsGCBrF692nSGt2rVymXnCQCAq6TZoFsDbpU7d26H/bptfU6/5sqVy+F5X19fyZYtm+2Y+ISFhZkA3vooUIDiRAASJyY2RmbsnCHtf24vx64fk1zBuWTGYzPk7WpvS4BPAJcRHkVrqOg0r08//VSyZs1q23/t2jX5/PPPZfz48dKwYUOpUqWKzJw5U9avXy8bN2506TkDAJDa0mzQ7UwDBw40NwTWx8mTJ119SgDSgVM3TknnXzvL5L8mS7QlWpoUamKKpdXKW8vVpwa4hKaPP/nkkw5ZaWrbtm0SFRXlsL906dJSsGBBW7ZafMhEAwC4ozS7ZFho6H8FiHT+l1Yvt9LtSpUq2Y65cOGCw+uio6NNRXPr6+MTEBBgHgCQGFpj4sfDP0rY5jC5FXVLMvhlkHdqvCPNizY381gBT6T1U7Zv327Sy+PSbDN/f38zBSyhbLWEMtFGjBjhlPMFAMBV0uxId5EiRUzgvHz5cts+nXutc7Vr1fpvVEm/Xr161fSoW61YsUJiY2PN3G8AeFBX71yVN1e/KYPXDTYBd+VcleX7p76XFsVaEHDDY2mG2BtvvCFff/21BAYGptj7kokGAHBHvq6eC3bo0CGH4mk7duwwc7I1BU2roGp18xIlSpggfMiQIWZN75YtW5rjy5QpI48//rh069bNLCumqWw9evSQ559/3hwHAA9i/en1Jti+ePui+Hr5SveHu0vncp3Fx9uHCwuPpp3dmmlWuXJl276YmBizbOfHH38sv/76q0RGRpqOcfvRbs1WIxMNAOBpXBp0b926VRo0aGDb7tu3r/nasWNHmTVrlrz99ttmLW9dd1sbbl0STJcIs+9V1152DbQbNWpkqpbrkiS6tjcAJNed6DsycftE+Xrf12a7SEgRGfPIGCmbvSwXFRAxbe6uXbscrkXnzp3NvO3+/fubAqV+fn4mW03bZXXgwAE5ceKELVsNAABP4dKgu379+mauZEJ0ruTIkSPNIyE6Kj537lwnnSEAT7P/8n4ZsGaAHL522Gw/X+p56Vu1rwT5Brn61IA0I1OmTPLQQw857MuQIYNZk9u6v2vXrqYzXdvpzJkzS8+ePU3AXbNmTRedNQAArpFmC6kBQGovBTZn7xyZ9NckiY6NlhxBOWRk7ZHySP5H+CCAZJgwYYItA02rkjdt2lSmTp3KtQQAeByCbgAe7+zNszJo7SDZen6ruRYNCzSUYbWHSbbAbB5/bYDEWrVqlcO2TgWbMmWKeQAA4MkIugF41Gj29gvb5WL4RckZnNNUIl92bJmM3jhabkTdMCnkA6oPkGeKP0NlcgAAAKSIRAfdulxXYuncLQBIS/44/oeM2TxGzoeft+0L9AmUOzF3zPcVclaQMXXHSIHMBVx4loDz6AohuhIIAABIo0G3Lvmhhc3uRYui6TG6bAgApKWAu++qvmIRx8KN1oC7aeGmpjq5rzfJP3BfxYoVk0KFCplVQ6yP/Pnzu/q0AABwe4m+w1y5cqVzzwQAnJRSriPccQNue39f+Fu85N6dikB6t2LFCjPvWh/ffPONWUe7aNGi0rBhQ1sQnjt3blefJgAAnht0P/roo849EwBwAp3DbZ9SHp9z4efMcdVCq/EZwG3pMp36UHfu3JH169fbgvDZs2dLVFSUWWd7z549rj5VAADcindyX/jnn39K+/btpXbt2nL69Gmz78svv5S1a9em5PkBwAPZ82/iAggtrgZ4Cq0sriPcgwcPlhEjRkivXr0kY8aMsn//flefGgAAbidZQff3339v1tsMCgqS7du3m/U31bVr1+S9995L6XMEgCS7EXlDxm0ZJxO2TUjU8VrNHHB3mlK+Zs0aE2hrOrnWa3n11VflypUr8vHHH5tiawAAIGUlq2rQqFGjZPr06dKhQweZN2+ebX+dOnXMcwDgKrGWWPnh0A8ycftEuXznstkX4BMgETH/dQ7GpXO5cwfnNsuHAe5MR7Y3bdpkKpjrlLFXXnlF5s6dK3ny5HH1qQEA4NaSFXQfOHBA6tWrd9f+kJAQuXr1akqcFwAk2d8X/5Yxm8bI7ku7zXbhzIXNutu3o2+b6uXKvqCatXha/+r9xcfbhysOt6bTwjTA1uBb53Zr4J09e3ZXnxYAAG4vWenloaGhcujQobv263xurYQKAKlJ52O/s/Ydaf9zexNwZ/DLIP2q9pOFTy2UOvnqSONCjWV8/fGSKziXw+t0hFv36/OAu9NO8RkzZkhwcLCMHTtW8ubNK+XLl5cePXrId999JxcvUtcAAIA0M9LdrVs3eeONN+SLL74w63KfOXNGNmzYIP369ZMhQ4ak/FkCQDyiYqLkq31fyfS/p0t4dLjZ17J4S3mj8huSIyiHw7EaWDco0MBUKdcgXedwa0o5I9zwFBkyZJDHH3/cPNSNGzdMZ7kuCTpu3Dhp166dlChRQnbv/i9TBAAAuDDoHjBggMTGxkqjRo0kPDzcpJoHBASYoLtnz54pdGoAkLA/T/1pCqUdu37MbFfIUcGkkpfPWT7B12iAzbJgwP8F4dmyZTOPrFmziq+vr+zbt4/LAwBAWgi6dXT7nXfekbfeesukmd+8eVPKli1rlhsBAGc6fv24CbbXnFpjtrMHZpc+VfpIi2ItxNsr2asgAm5PO8u3bt1q1uXW0e1169bJrVu3JF++fKaS+ZQpU8xXAACQBoJuK39/fxNsA4Cz3Yq6JTN2zpA5e+dIdGy0+Hr7Svsy7eWVCq9IRn86/ID70eXBNMjWuiwaXE+YMMEUVCtWrBgXDwCAtBZ0a2Oto90JWbFixYOcEwDYWCwWWXpkqVlv++Lt/wo9aXG0/tX6S5GQIlwpIJHef/99036XLFmSawYAQFoPuitVquSwHRUVJTt27DDFVzp27JhS5wbAw+25tEfCNoWZpcBUgUwFTLBdL3+9e3b8AbibrtGtj/vRIqkAAMDFQbempMVn+PDhZn43ADyIS7cvyeS/JsvCgwvNutpBvkHycoWXpUPZDuLv48/FBZJh1qxZUqhQIXn44YdNBgkAAEgHc7rjat++vVSvXl0++OCDlHxbAB4iKjZK5u+fL1N3TJUbUTfMvuZFm0vvyr0ld4bcrj49IF177bXX5JtvvpGjR49K586dTZutlcsBAIBzpWipX12rOzAwMCXfEoCH2HBmgzz747MydstYE3CXyVZG5jSbI2GPhBFwAylAq5OfPXtW3n77bVmyZIkUKFBA2rRpI7/++isj3wAApLWR7latWjlsa5qaNuS6FMmQIUNS6twApHFnb56VKxFXkvy6rAFZJU/GPOb7UzdOyQdbP5DlJ5bbnutVuZc8U/wZs642gJQTEBAgbdu2NY/jx4+blPPXX39doqOjZc+ePSz9CQCAq4PuI0eOSOHChSUkJMRhv7e3t5QqVUpGjhwpTZo0SelzBJBGA+7mi5tLZExkkl+r87IXNF8gPx/9WWbunimRsZHi4+UjbUu3lVcrviohAY7/xgBIedp2a0FC7TiPiYnhEgMAkBaC7hIlSpgR7ZkzZ5rt5557TiZNmiS5cztvrqUG+dobH5f2zGuqnK4xunr1aofnXnnlFZk+fbrTzgmAmBHu5ATcSl/X+dfOcvnOZbNdI08NGVBtgBTPWpxLCzhRRESELFy40FQoX7t2rTRv3lw+/vhjefzxx00QDgAAXBx0x612+ssvv8itW7fEmbZs2eLQA6/Lkj322GPy7LPP2vZ169bNjLJbBQcHO/WcADw4DbjzZcwn/ar2k0YFG7EEGOBk2lk9b948M5e7S5cupqhajhw5uO4AAKTl6uWpseRIzpw5HbbHjBkjxYoVk0cffdQhyA4NDXX6uQBIOW1KtpG3qr0lgb4UXwRSg2aAFSxYUIoWLWoyxOJmiVnpSDgAAHBR0K1zv/QRd19qiYyMlK+++kr69u3r8HO//vprs18D7xYtWphibvca7db0On1YXb9+3ennDsBR65KtCbiBVNShQwcySgAASA/p5Z06dTLVT9WdO3fk1VdflQwZMqRKL/nixYvl6tWr5hysXnjhBSlUqJDkzZtXdu7cKf3795cDBw7c8xzCwsJkxIgRTjlHAADSIq1UnpKmTZtmHseOHTPb5cqVk6FDh0qzZs1s9whvvvmmSWnXju6mTZvK1KlTnVoHBgCAdB90d+zY0WG7ffv2kpo+//xz05hrgG318ssv274vX7685MmTRxo1aiSHDx82aejxGThwoBkttx/p1jluAAAgcfLnz2+mfGmRVe2Unz17tjz99NPy119/mQC8T58+8tNPP8mCBQvMqic9evQwS46uW7eOSwwA8ChJCrqtVctdQSuY//HHH/cdRa9Ro4b5eujQoQSDbh2pt47WAwCApNPpXPZGjx5tRr43btxoAnLtKJ87d640bNjQdg9RpkwZ83zNmjW55AAAj5Fu1gfRxjpXrlzy5JNP3vO4HTt2mK864g0AAJxPVxnRNHJd0aRWrVqybds2iYqKksaNG9uOKV26tCnktmHDBj4SAIBHeaDq5aklNjbWBN2a3u7r+3+nrCnk2ov+xBNPSPbs2c2cbk1nq1evnlSoUMGl5wwAgLvbtWuXCbJ1/nbGjBll0aJFUrZsWdMB7u/vL1myZHE4Xudznzt3LsH3o9ApAMAdpYugW9PKT5w4YdYVtacNuj43ceJE07uu87Jbt24tgwcPdtm5Ap7geuR1mbU7ZYsyAUh/SpUqZQLsa9euyXfffWc6xxNaiiwxKHQKAHBH6SLobtKkSbxrgmuQ/SCNO4CkiYmNkcWHFstH2z+SKxFXuHyAh9PO7+LFi5vvq1SpIlu2bJGPPvpInnvuObPMp644Yj/aff78ebO8Z0IodAoAcEfpIugG4Ho7LuyQsM1hsvfSXrOdL2M+OX3ztKtPC0Aamw6mKeIagPv5+cny5ctNBprS5Tw1a03T0RNCoVMAgDsi6AZwTxfCL8iEbRNk6ZGlZjujX0Z5vdLrUjFnRWn3czuuHuChdFRal/HU4mg3btwwNVZWrVolv/76q1kirGvXrmZ5zmzZsknmzJmlZ8+eJuCmcjkAwNMQdAOIV2RMpHy590v5ZOcncjv6tniJl7Qq0Up6PtxTsgdll7M3z4q/j785Lqn0dVkDsnLlgXTswoUL0qFDBzl79qwJsrWAqQbcjz32mHl+woQJ4u3tbUa6dfS7adOmMnXqVFefNgAAqY6gG8Bd1pxaI2M3j5UTN06YbR3VHlh9oJTLUc52TJ6MeWRpy6XJmtutAbe+HkD6petw30tgYKBMmTLFPAAA8GQE3QBsjl47KuO2jJO1p9ea7RxBOaRvlb7yZNEnxdvL+64rpYEzwTMAAACQMIJuAHIz8qbM2DlDvtz3pUTHRouvt690KNtBXq7wsmTwy8AVAgAAAJKJoBvwYLGWWFlyeIlM3D5R/r39r9lXL389ebva21IocyFXnx4AAACQ7hF0Ax5q97+7JWxTmOz8d6fZ1iBbg20NugEAAACkDIJuwMPoiPak7ZNk0aFFZjvYN1herfiqtC/TXvx8/Fx9egAAAIBbIegGPERUbJTM3TdXpv89XW5G3TT7nir2lPSu3FtyBud09ekBAAAAbomgG/AA60+vlzFbxpjq5Kps9rJmCbBKuSq5+tQAAAAAt0bQDbixkzdOyvtb3peVJ1ea7WyB2czI9tPFn453CTAAAAAAKYugG3BD4VHh8tmuz2T2ntkSGRspvl6+0rZMWzN3O7N/ZlefHgAAAOAxCLoBN2KxWOSXo7/Ih9s+lAvhF8y+WnlqSf/q/aVYlmKuPj0AAADA4xB0A25i36V9MmbzGNl+YbvZzpcxn1kCrEGBBuLl5eXq0wMAAAA8EkE3kM5duXNFJv81Wb775zuxiEWCfIPkpfIvScdyHSXAJ8DVpwcAAAB4NIJuIJ2Kjo2Wbw98Kx/v+FhuRN4w+5oVaSZ9q/SV0Ayhrj49AAAAAATdQPq0+exmCdscJoeuHjLbpbKWkgHVB0jV0KquPjUAAAAAdhjpBtKRMzfPyAdbP5Dfj/9utkMCQqTXw72kdYnW4uPt4+rTAwAAABAHQTeQDtyJviMzd8+Uz3d/LhExEWaN7TYl20iPh3uYwBsAAABA2kTQDaTxJcB0VPvDrR/KmVtnzL5qodWkf7X+UipbKVefHgAAAID7IOgG0qiDVw6aJcA2n9tstrU4Wr+q/aRJoSYsAQYAAACkEwTdQBpzLeKaTN0xVeYfmC8xlhiz7FeXh7pI54c6m+XAAAAAAKQf3pKGDR8+3Izo2T9Kly5te/7OnTvSvXt3yZ49u2TMmFFat24t58+fd+k5A8kVExsjC/5ZIM0XNZe5++eagPuxQo/JDy1/kNcrvU7ADQAAAKRDaX6ku1y5cvLHH3/Ytn19/++U+/TpIz/99JMsWLBAQkJCpEePHtKqVStZt26di84WSJ7t57ebVPJ9l/eZ7eJZikv/6v2lZp6aXFIAAAAgHUvzQbcG2aGhoXftv3btmnz++ecyd+5cadiwodk3c+ZMKVOmjGzcuFFq1iRYQdp3/tZ5Gb9tvPx89Geznck/k3Sv1F3alGojft5+rj49AAAAAO4edB88eFDy5s0rgYGBUqtWLQkLC5OCBQvKtm3bJCoqSho3bmw7VlPP9bkNGzbcM+iOiIgwD6vr1687/fcAHP4GYyJkzp458umuT+V29G3xEi9pXbK19Hy4p2QLzMbFAgAAANxEmg66a9SoIbNmzZJSpUrJ2bNnZcSIEfLII4/I7t275dy5c+Lv7y9ZsmRxeE3u3LnNc/eigbu+F+CKJcBWnVwl47aMk1M3T5l9lXJWkoE1BkrZ7GX5QAAAAAA3k6aD7mbNmtm+r1ChggnCCxUqJN9++60EBSW/ivPAgQOlb9++DiPdBQoUeODzBe7lyLUjMm7zOFl35r+aA7mCcknfqn3liSJPsAQYAAAA4KbSdNAdl45qlyxZUg4dOiSPPfaYREZGytWrVx1Gu7V6eXxzwO0FBASYB5AabkTekOl/T5e5++ZKtCXazNXuWK6jdCvfTYL9gvkQAAAAADeWppcMi+vmzZty+PBhyZMnj1SpUkX8/Pxk+fLltucPHDggJ06cMHO/AVeLtcTKooOLzBJgc/bOMQF3/fz1ZfHTi+WNym8QcANI13SqVrVq1SRTpkySK1cuadmypWmH7bG0JwAAaTzo7tevn6xevVqOHTsm69evl2eeeUZ8fHykbdu2Zomwrl27mjTxlStXmsJqnTt3NgE3lcvhajsv7pR2P7WToeuHyuU7l6Vw5sIyrfE0mdxoshTMXNDVpwcAD0zb5+7du5sVQ37//XdT3LRJkyZy69Yth6U9lyxZYpb21OPPnDljlvYEAMCTpOn08lOnTpkA+9KlS5IzZ06pW7euadz1ezVhwgTx9vaW1q1bm2rkTZs2lalTp7r6tOHB/r39r0zYNkF+PPyj2c7gl0Feq/iavFD6BfHzYQkwAO5j2bJlDtta+FRHvLUTvF69eiztCQBAegi6582bd8/ndRmxKVOmmAfgSlExUfL1vq9l+s7pcivqv1Gep4s9Lb2r9JYcQTn4cAC4vWvXrpmv2bL9t+xhcpb2ZElPAIA7StNBN5Ae/HnqT7ME2LHrx8x2+RzlZUD1AVIhZwVXnxoApIrY2Fjp3bu31KlTRx566CGzLzlLe7KkJwDAHRF0A8l04voJE2yvPrXabGcPzG5Gtp8q9pR4e6XpcgkAkKJ0bvfu3btl7dq1D/Q+LOkJAHBHBN1AAmJiY2T7he1yMfyi5AzOKZVzVRYfbx8JjwqXGTtnmIrkUbFR4uvlK+3KtJNXKr4imfwzcT0BeJQePXrI0qVLZc2aNZI/f37bfl2+M6lLe7KkJwDAHRF0A/H44/gfMmbzGDkfft62L3dwbmlcqLH8fux3uXD7gtlXJ28debv621I0pCjXEYBHsVgs0rNnT1m0aJGsWrVKihQp4vC8/dKeWvBUsbQnAMATEXQD8QTcfVf1FYtYHPZrAK7F0lSBTAXk7Wpvy6P5HxUvLy+uIQCPTCmfO3eu/PDDD2atbus8bV3SMygoyGFpTy2uljlzZhOks7QnAMDTEHQDcVLKdYQ7bsBtL6NfRvm+xfcS5BfEtQPgsaZNm2a+1q9f32H/zJkzpVOnTuZ7lvYEAICgG3Cgc7jtU8rjczPqpuy+tFuqhVbj6gHw6PTy+2FpTwAARCixDNjRomkpeRwAAAAAz0bQDdjRKuUpeRwAAAAAz0bQDdjRZcG0SrmXxF8cTfeHBoea4wAAAADgfgi6ATu6DveA6gPM93EDb+t2/+r9zXEAAAAAcD8E3UAcuhb3+PrjJVdwLof9OgKu+/V5AAAAAEgMlgwD4qGBdYMCDUw1cy2apnO4NaWcEW4AAAAASUHQDSRAA2yWBQMAAADwIEgvBwAAAADASQi6AQAAAABwEoJuAAAAAACchDndAADA7VWtWlXOnTvn6tOAC509e5brD8AlCLoBAIDb04D79OnTrj4NpAGZMmVy9SkA8DAE3QAAwO2FhoYm63WxN2+l+LkgZXhnzJCsgPvdd9/lIwCQqgi6AQCA29u6dWuyXndx8scpfi5IGTl79uBSAkgXKKQGAAAAAICTEHQDAAAAAOCJQXdYWJhUq1bNzL/JlSuXtGzZUg4cOOBwTP369cXLy8vh8eqrr7rsnAEAAAAASBdB9+rVq6V79+6yceNG+f333yUqKkqaNGkit245FjXp1q2bWQbC+hg3bpzLzhkAAAAAgHRRSG3ZsmUO27NmzTIj3tu2bZN69erZ9gcHBye7KikAAAAAAB450h3XtWvXzNds2bI57P/6668lR44c8tBDD8nAgQMlPDz8nu8TEREh169fd3gAAAAAAOBRI932YmNjpXfv3lKnTh0TXFu98MILUqhQIcmbN6/s3LlT+vfvb+Z9L1y48J5zxUeMGJFKZw4AAAAA8FTpJujWud27d++WtWvXOux/+eWXbd+XL19e8uTJI40aNZLDhw9LsWLF4n0vHQ3v27evbVtHugsUKODEswcAAAAAeKJ0EXT36NFDli5dKmvWrJH8+fPf89gaNWqYr4cOHUow6A4ICDAPAAAAAAA8Nui2WCzSs2dPWbRokaxatUqKFCly39fs2LHDfNURbwAAAAAAXMk3raeUz507V3744QezVve5c+fM/pCQEAkKCjIp5Pr8E088IdmzZzdzuvv06WMqm1eoUMHVpw8AAAAA8HBpunr5tGnTTMXy+vXrm5Fr62P+/PnmeX9/f/njjz/M2t2lS5eWN998U1q3bi1Llixx9akDAODWdMpXixYtTCFTLy8vWbx48V3ZakOHDjXttnaUN27cWA4ePOiy8wUAwFXS9Ei3Ntj3osXPVq9enWrnAwAA/nPr1i2pWLGidOnSRVq1anXXZRk3bpxMmjRJZs+ebaaHDRkyRJo2bSp79+6VwMBALiMAwGOk6aAbAACkTc2aNTOPhDrNJ06cKIMHD5ann37a7JszZ47kzp3bjIg///zzqXy2AAC4TppOLwcAAOnP0aNHTR0WTSm30nosusLIhg0bEnxdRESEWcbT/gEAQHpH0A0AAFKUtfCpjmzb023rc/EJCwszwbn1odPIAABI7wi6AQBAmjBw4EBTQNX6OHnypKtPCQCAB0bQDQAAUlRoaKj5ev78eYf9um19Lj4BAQGSOXNmhwcAAOkdQTcAAEhRWq1cg+vly5fb9un87E2bNkmtWrW42gAAj0L1cgAAkGQ3b96UQ4cOORRP27Fjh2TLlk0KFiwovXv3llGjRkmJEiVsS4bpmt4tW7bkagMAPApBNwAASLKtW7dKgwYNbNt9+/Y1Xzt27CizZs2St99+26zl/fLLL8vVq1elbt26smzZMtboBgB4HIJuAACQZPXr1zfrcSfEy8tLRo4caR4AAHgy5nQDAAAAAOAkBN0AAAAAADgJQTcAAAAAAE5C0A0AAAAAgJMQdAMAAAAA4CQE3QAAAAAAOAlBNwAAAAAATkLQDQAAAAAAQTcAAAAAAOkLI90AAAAAADiJr7Pe2N2dvnpbrtyKTPLrsmbwl3xZgpxyTgAAAACAtIWgO5kBd8MPVklEdGySXxvg6y0r+tUn8AYAAAAAD0B6eTLoCHdyAm6lr0vOCDkAAAAAIP0h6AYAAAAAwEncJuieMmWKFC5cWAIDA6VGjRqyefNmV58SAAAAAMDDuUXQPX/+fOnbt68MGzZMtm/fLhUrVpSmTZvKhQsXXH1qAAAAAAAP5hZB9/jx46Vbt27SuXNnKVu2rEyfPl2Cg4Pliy++cPWpAQAAAAA8WLoPuiMjI2Xbtm3SuHFj2z5vb2+zvWHDhnhfExERIdevX3d4AAAAAACQ0tJ90P3vv/9KTEyM5M6d22G/bp87dy7e14SFhUlISIjtUaBAgVQ6WwAAAACAJ0n3QXdyDBw4UK5du2Z7nDx50tWnBAAAAABwQ76SzuXIkUN8fHzk/PnzDvt1OzQ0NN7XBAQEmAcAAAAAAM6U7ke6/f39pUqVKrJ8+XLbvtjYWLNdq1Ytl54bAAAAAMCzpfuRbqXLhXXs2FGqVq0q1atXl4kTJ8qtW7dMNXMAAAAAAFzFLYLu5557Ti5evChDhw41xdMqVaoky5Ytu6u4GgAAAAAAqcktgm7Vo0cP8wAAAAAAIK1I93O6XSFrBn8J8E3epdPX6esBAPAEU6ZMkcKFC0tgYKDUqFFDNm/e7OpTAgAgVbnNSHdqypclSFb0qy9XbkUm+bUacOvrAQBwd/Pnzzd1V6ZPn24Cbq250rRpUzlw4IDkypXL1acHAECqIOhOJg2cCZ4BAEjY+PHjpVu3brbCphp8//TTT/LFF1/IgAEDuHQAAI9AejkAAEhxkZGRsm3bNmncuPH/3XR4e5vtDRs2cMUBAB6DkW4RsVgs5mJcv37d1Z8HAAB3sbZP1vYqPfj3338lJibmrpVEdHv//v3xviYiIsI8rK5du+by9vnG7dsu+9m4t4BU+ruIuR3DR5GGpca/D/wNpG3XXdhGJLZ9JujWBvXGDXMxChQokBqfDQAAyW6vQkJC3PbqhYWFyYgRI+7aT/uMePV/mwsDCXnNff9NRPr5G7hf+0zQLSJ58+aVkydPSqZMmcTLy+uBezv05kDfL3PmzA/0Xp6E68Y1428t7eK/T9dfN+1B1wZd26v0IkeOHOLj4yPnz5932K/boaGh8b5m4MCBpvCaVWxsrFy+fFmyZ8/+wO0z+G8Z/A2Av4GUltj2maD7/88xy58/f4p+AHqDRdDNdUsN/K1x3VILf2uuvW7pbYTb399fqlSpIsuXL5eWLVvagmjd7tGjR7yvCQgIMA97WbJkSZXz9ST8twz+BsDfQMpJTPtM0A0AAJxCR607duwoVatWlerVq5slw27dumWrZg4AgCcg6AYAAE7x3HPPycWLF2Xo0KFy7tw5qVSpkixbtuyu4moAALgzgu4Upmlxw4YNuys9Dlw3/tbSBv4b5Zrxt5a6NJU8oXRypC7+/QN/A+BvwDW8LOlp/REAAAAAANIRb1efAAAAAAAA7oqgGwAAAAAAJyHoBgAAAADASQi6U9iUKVOkcOHCEhgYKDVq1JDNmzen9I9It8LCwqRatWqSKVMmyZUrl1m39cCBAw7H3LlzR7p37y7Zs2eXjBkzSuvWreX8+fMuO+e0ZsyYMeLl5SW9e/e27eOaxe/06dPSvn1787cUFBQk5cuXl61bt9qe13IWWlE5T5485vnGjRvLwYMHxVPFxMTIkCFDpEiRIuZ6FCtWTN59911znay4ZiJr1qyRFi1aSN68ec1/i4sXL3a4jom5RpcvX5Z27dqZNVJ1DequXbvKzZs3U+2zhue5398t3F9i7sHg3qZNmyYVKlSwrc9dq1Yt+eWXX1x9Wh6DoDsFzZ8/36xJqtXLt2/fLhUrVpSmTZvKhQsXUvLHpFurV682AfXGjRvl999/l6ioKGnSpIlZs9WqT58+smTJElmwYIE5/syZM9KqVSuXnndasWXLFvnkk0/MP5j2uGZ3u3LlitSpU0f8/PxMg7J371758MMPJWvWrLZjxo0bJ5MmTZLp06fLpk2bJEOGDOa/V+3E8ERjx441DfLHH38s+/btM9t6jSZPnmw7hmsm5t8r/bddO1jjk5hrpAH3nj17zL+DS5cuNQHRyy+/nCqfMzzT/f5u4f4Scw8G95Y/f34zeLNt2zYzCNGwYUN5+umnTXuEVKDVy5EyqlevbunevbttOyYmxpI3b15LWFgYlzgeFy5c0CE0y+rVq8321atXLX5+fpYFCxbYjtm3b585ZsOGDR59DW/cuGEpUaKE5ffff7c8+uijljfeeMPs55rFr3///pa6desmeD1jY2MtoaGhlvfff9+2T69lQECA5ZtvvrF4oieffNLSpUsXh32tWrWytGvXznzPNbub/tu0aNEi23ZirtHevXvN67Zs2WI75pdffrF4eXlZTp8+7YRPFrj33y08U9x7MHimrFmzWj777DNXn4ZHYKQ7hURGRpqeI00ltPL29jbbGzZsSKkf41auXbtmvmbLls181eunPa/217B06dJSsGBBj7+G2jv95JNPOlwbrlnCfvzxR6latao8++yzJo3u4Ycflk8//dT2/NGjR+XcuXMO1zMkJMRMCfHU/15r164ty5cvl3/++cds//3337J27Vpp1qyZ2eaa3V9irpF+1ZRy/fu00uO1vdCRcQBwxT0YPG9K2bx580ymg6aZw/l8U+FneIR///3X/AHnzp3bYb9u79+/32XnlVbFxsaaecmaAvzQQw+ZfXqz6u/vb25I415Dfc5T6T+KOl1B08vj4prF78iRIyZVWqd7DBo0yFy7Xr16mb+vjh072v6e4vvv1VP/1gYMGCDXr183HV0+Pj7m37PRo0ebVGjFNbu/xFwj/aodQfZ8fX3Nja+n/u0BcP09GDzDrl27TJCtU560dtKiRYukbNmyrj4tj0DQDZeN3O7evduMpCFhJ0+elDfeeMPMv9LifEj8DYWOJL733ntmW0e69e9N59lq0I27ffvtt/L111/L3LlzpVy5crJjxw5zU6aFl7hmAOA+uAfzXKVKlTLtu2Y6fPfdd6Z91/n+BN7OR3p5CsmRI4cZHYpbaVu3Q0NDU+rHuIUePXqY4kErV640RR2s9Dppmv7Vq1cdjvfka6gp91qIr3LlymY0TB/6j6MWatLvdQSNa3Y3rRwdtwEpU6aMnDhxwnxv/Xviv9f/89Zbb5nR7ueff95Uen/xxRdNkT6teMs1S5zE/F3p17jFNaOjo01Fc0/9dw6A6+/B4Bk046948eJSpUoV075rgcWPPvrI1aflEQi6U/CPWP+AdU6k/WibbjNX4j9av0X/sddUlhUrVpiliezp9dNq0/bXUJez0EDJU69ho0aNTCqQ9kpaHzqCqym/1u+5ZnfTlLm4S6HoXOVChQqZ7/VvTwMc+781Ta3WObWe+rcWHh5u5hXb045E/XdMcc3uLzHXSL9qx6J2qFnpv4d6nXXuNwC44h4MnknbnoiICFefhkcgvTwF6fxRTdPQQKh69eoyceJEU6Cgc+fOKflj0nU6k6au/vDDD2adSOv8RS00pOvZ6lddr1avo85v1DUEe/bsaW5Sa9asKZ5Ir1Pc+Va6BJGuPW3dzzW7m47QamEwTS9v06aNbN68WWbMmGEeyrrW+ahRo6REiRLm5kPXqNZUal271BPpGr46h1sLF2p6+V9//SXjx4+XLl26mOe5Zv/R9bQPHTrkUDxNO8D03yy9dvf7u9KMi8cff1y6detmpjto8Ui9EdYMAz0OcMXfLdzf/e7B4P4GDhxoiqPqf/M3btwwfw+rVq2SX3/91dWn5hlcXT7d3UyePNlSsGBBi7+/v1lCbOPGja4+pTRD/9zie8ycOdN2zO3bty2vv/66WcIgODjY8swzz1jOnj3r0vNOa+yXDFNcs/gtWbLE8tBDD5nlmkqXLm2ZMWOGw/O6vNOQIUMsuXPnNsc0atTIcuDAASd/emnX9evXzd+V/vsVGBhoKVq0qOWdd96xRERE2I7hmlksK1eujPffsY4dOyb6Gl26dMnStm1bS8aMGS2ZM2e2dO7c2SwLCLjq7xbuLzH3YHBvuixooUKFTIySM2dO0z799ttvrj4tj+Gl/+fqwB8AAAAAAHfEnG4AAAAAAJyEoBsAAAAAACch6AYAAAAAwEkIugEAAAAAcBKCbgAAAAAAnISgGwAAAAAAJyHoBgAAAADASQi6AQAAAABwEoJuAAAAwE106tRJWrZs6erTAGCHoBuArZH28vIyD39/fylevLiMHDlSoqOjuUIAAKQB1nY6ocfw4cPlo48+klmzZrn6VAHY8bXfAODZHn/8cZk5c6ZERETIzz//LN27dxc/Pz8ZOHCgS88rMjLSdAQAAODJzp49a/t+/vz5MnToUDlw4IBtX8aMGc0DQNrCSDcAm4CAAAkNDZVChQrJa6+9Jo0bN5Yff/xRrly5Ih06dJCsWbNKcHCwNGvWTA4ePGheY7FYJGfOnPLdd9/Z3qdSpUqSJ08e2/batWvNe4eHh5vtq1evyksvvWRelzlzZmnYsKH8/ffftuO1p17f47PPPpMiRYpIYGAgnxIAwONpG219hISEmNFt+30acMdNL69fv7707NlTevfubdrx3Llzy6effiq3bt2Szp07S6ZMmUx22y+//OJwfXfv3m3ae31Pfc2LL74o//77r8d/BkByEHQDSFBQUJAZZdYGfOvWrSYA37Bhgwm0n3jiCYmKijINfr169WTVqlXmNRqg79u3T27fvi379+83+1avXi3VqlUzAbt69tln5cKFC6aB37Ztm1SuXFkaNWokly9ftv3sQ4cOyffffy8LFy6UHTt28CkBAJBMs2fPlhw5csjmzZtNAK4d69oW165dW7Zv3y5NmjQxQbV957h2iD/88MOm/V+2bJmcP39e2rRpw2cAJANBN4C7aFD9xx9/yK+//ioFCxY0wbaOOj/yyCNSsWJF+frrr+X06dOyePFiWy+6Nehes2aNaaTt9+nXRx991DbqrY3+ggULpGrVqlKiRAn54IMPJEuWLA6j5Rrsz5kzx7xXhQoV+JQAAEgmbbsHDx5s2lydMqYZZBqEd+vWzezTNPVLly7Jzp07zfEff/yxaX/fe+89KV26tPn+iy++kJUrV8o///zD5wAkEUE3AJulS5eaNDJtjDWl7LnnnjOj3L6+vlKjRg3bcdmzZ5dSpUqZEW2lAfXevXvl4sWLZlRbA25r0K2j4evXrzfbStPIb968ad7DOvdMH0ePHpXDhw/bfoamuGv6OQAAeDD2ndc+Pj6mDS5fvrxtn6aPK81Cs7bVGmDbt9MafCv7thpA4lBIDYBNgwYNZNq0aaZoWd68eU2wraPc96MNd7Zs2UzArY/Ro0ebuWVjx46VLVu2mMBbU9iUBtw639s6Cm5PR7utMmTIwCcDAEAK0KKo9nRqmP0+3VaxsbG2trpFixamHY/LvmYLgMQh6AbgEOhqMRV7ZcqUMcuGbdq0yRY4awqaVkstW7asrbHW1PMffvhB9uzZI3Xr1jXzt7UK+ieffGLSyK1BtM7fPnfunAnoCxcuzNUHACCN0bZa66poO63tNYAHQ3o5gHvSuV5PP/20mfel87E15ax9+/aSL18+s99K08e/+eYbU3Vc09C8vb1NgTWd/22dz620InqtWrVMZdXffvtNjh07ZtLP33nnHVOsBQAAuJYuGarFTdu2bWsy1jSlXOu8aLXzmJgYPh4giQi6AdyXrt1dpUoVad68uQmYtdCaruNtn5qmgbU2xNa520q/j7tPR8X1tRqQa+NdsmRJef755+X48eO2OWUAAMB1dIrZunXrTBuulc11GpkuOabTwLRTHUDSeFn07hkAAAAAAKQ4uqoAAAAAAHASgm4AAAAAAJyEoBsAAAAAACch6AYAAAAAwEkIugEAAAAAcBKCbgAAAAAAnISgGwAAAAAAJyHoBgAAAADASQi6AQAAAABwEoJuAAAAAACchKAbAAAAAAAnIegGAAAAAECc4/8BG6hf5E6PdMwAAAAASUVORK5CYII="
+ "image/png": "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UDWnzN6XBPmM/s7k+8Thw4c1tWvXVgHO3d1dU65cOc1nn32W6oLAkt6TBF0JohI8ZcoQmUarf//+TyQulnaunvb7J+bNm6fx8PBQ06GkZYnniewrZpubrcbW7GSrcdCcbDFeWXp+YOjf++XLlzWtW7dWcVVu0MmNu4SEBI09cZB/zN3aTkRERERERGSLOKabiIiIiIiIyESYdBMRERERERGZCJNuIiIiIiIiIhNh0k1ERERERERkIky6iYiIiIiIiEyESTcRERERERGRiTDpJiIiIiIiIjIRJt1EREREREREJsKkm4jMYvv27XBwcEBERATPABERkYVivCZ6fky6iShDvXv3Volx2kdISAiPGhERkYVgvCaybM7m3gEismytWrXCwoULU63Lnz+/2faHiIiInsR4TWS52NJNRJlyc3ODv79/qke/fv3QoUOHVNsNGzYMTZo0SVlOTk7GlClTUKxYMXh4eKBy5cr47bffeLSJiIhMgPGayHKxpZuITEIS7sWLF2Pu3LkoVaoUdu7ciR49eqhW8saNG/OoExERWQDGayLTY9JNRJlat24dPD09U5Zbt26NnDlzZvo9cXFx+Oyzz7B582bUrVtXrStevDh2796NefPmMekmIiIyMsZrIsvFpJuIMtW0aVPMmTMnZVkS7rFjx2b6PVJoLSYmBi+88EKq9fHx8ahatSqPOBERkZExXhNZLibdRJQpSbJLliyZap2joyM0Gk2qdQkJCSn/f/Tokfq6fv16FCxY8IkxZ0RERGRcjNdElotJNxFlmYzLPnnyZKp1x44dg4uLi/p/UFCQSq6vXr3KruRERERmwnhNZBmYdBNRljVr1gxffPEFFi1apMZsS8E0ScJ1Xce9vLwwatQoDB8+XFUxb9CgASIjI7Fnzx7kypULvXr14lEnIiIyMcZrIsvApJuIsqxly5YYP3483nvvPcTGxqJv377o2bMnTpw4kbLNxx9/rO6wS1XUS5cuIXfu3KhWrRo++OADHnEiIqJswHhNZBkcNGkHZhIRERERERGRUTga52WIiIiIiIiIKC0m3UREREREREQmwqSbiIiIiIiIyESYdBMRERERERGZCJNuIiIiIiIiIhNh0k1ERERERERkIky6iYiIiIiIiEyESTcRERERERGRiTDpJiIiIiIiIjIRJt1EREREREREJsKkm4iIiIiIiMhEmHQTERERERERwTT+DyL9jcFaLIqHAAAAAElFTkSuQmCC"
},
"metadata": {},
"output_type": "display_data",
@@ -852,11 +2939,6 @@
}
],
"execution_count": null
- },
- {
- "cell_type": "markdown",
- "source": "At **t=1**, demand (15 MW) is below the minimum load (30 MW). The solver\nkeeps the unit off (`commit=0`), so `power=0` and `fuel=0` \u2014 the `active`\nparameter enforces this. Demand is met by the backup source.\n\nAt **t=2** and **t=3**, the unit commits and operates on the PWL curve.",
- "metadata": {}
}
],
"metadata": {
diff --git a/linopy/__init__.py b/linopy/__init__.py
index b1dc33b9..aa14b767 100644
--- a/linopy/__init__.py
+++ b/linopy/__init__.py
@@ -20,7 +20,7 @@
from linopy.io import read_netcdf
from linopy.model import Model, Variable, Variables, available_solvers
from linopy.objective import Objective
-from linopy.piecewise import breakpoints, piecewise, segments, slopes_to_points
+from linopy.piecewise import breakpoints, segments, slopes_to_points, tangent_lines
from linopy.remote import RemoteHandler
try:
@@ -44,7 +44,7 @@
"Variables",
"available_solvers",
"breakpoints",
- "piecewise",
+ "tangent_lines",
"segments",
"slopes_to_points",
"align",
diff --git a/linopy/constants.py b/linopy/constants.py
index f3c05a55..0d8d4adc 100644
--- a/linopy/constants.py
+++ b/linopy/constants.py
@@ -46,9 +46,6 @@
PWL_FILL_SUFFIX = "_fill"
PWL_BINARY_SUFFIX = "_binary"
PWL_SELECT_SUFFIX = "_select"
-PWL_AUX_SUFFIX = "_aux"
-PWL_LP_SUFFIX = "_lp"
-PWL_LP_DOMAIN_SUFFIX = "_lp_domain"
PWL_INC_BINARY_SUFFIX = "_inc_binary"
PWL_INC_LINK_SUFFIX = "_inc_link"
PWL_INC_ORDER_SUFFIX = "_inc_order"
diff --git a/linopy/expressions.py b/linopy/expressions.py
index ca491c3e..88c2099d 100644
--- a/linopy/expressions.py
+++ b/linopy/expressions.py
@@ -92,33 +92,12 @@
if TYPE_CHECKING:
from linopy.constraints import AnonymousScalarConstraint, Constraint
from linopy.model import Model
- from linopy.piecewise import PiecewiseConstraintDescriptor, PiecewiseExpression
from linopy.variables import ScalarVariable, Variable
FILL_VALUE = {"vars": -1, "coeffs": np.nan, "const": np.nan}
-def _to_piecewise_constraint_descriptor(
- lhs: Any, rhs: Any, operator: str
-) -> PiecewiseConstraintDescriptor | None:
- """Build a piecewise descriptor for reversed RHS syntax if applicable."""
- from linopy.piecewise import PiecewiseExpression
-
- if not isinstance(rhs, PiecewiseExpression):
- return None
-
- if operator == "<=":
- return rhs.__ge__(lhs)
- if operator == ">=":
- return rhs.__le__(lhs)
- if operator == "==":
- return rhs.__eq__(lhs)
-
- msg = f"Unsupported operator '{operator}' for piecewise dispatch."
- raise ValueError(msg)
-
-
def exprwrap(
method: Callable, *default_args: Any, **new_default_kwargs: Any
) -> Callable:
@@ -669,40 +648,13 @@ def __div__(self: GenericExpression, other: SideLike) -> GenericExpression:
def __truediv__(self: GenericExpression, other: SideLike) -> GenericExpression:
return self.__div__(other)
- @overload
- def __le__(self, rhs: PiecewiseExpression) -> PiecewiseConstraintDescriptor: ...
-
- @overload
- def __le__(self, rhs: SideLike) -> Constraint: ...
-
- def __le__(self, rhs: SideLike) -> Constraint | PiecewiseConstraintDescriptor:
- descriptor = _to_piecewise_constraint_descriptor(self, rhs, "<=")
- if descriptor is not None:
- return descriptor
+ def __le__(self, rhs: SideLike) -> Constraint:
return self.to_constraint(LESS_EQUAL, rhs)
- @overload
- def __ge__(self, rhs: PiecewiseExpression) -> PiecewiseConstraintDescriptor: ...
-
- @overload
- def __ge__(self, rhs: SideLike) -> Constraint: ...
-
- def __ge__(self, rhs: SideLike) -> Constraint | PiecewiseConstraintDescriptor:
- descriptor = _to_piecewise_constraint_descriptor(self, rhs, ">=")
- if descriptor is not None:
- return descriptor
+ def __ge__(self, rhs: SideLike) -> Constraint:
return self.to_constraint(GREATER_EQUAL, rhs)
- @overload # type: ignore[override]
- def __eq__(self, rhs: PiecewiseExpression) -> PiecewiseConstraintDescriptor: ...
-
- @overload
- def __eq__(self, rhs: SideLike) -> Constraint: ...
-
- def __eq__(self, rhs: SideLike) -> Constraint | PiecewiseConstraintDescriptor:
- descriptor = _to_piecewise_constraint_descriptor(self, rhs, "==")
- if descriptor is not None:
- return descriptor
+ def __eq__(self, rhs: SideLike) -> Constraint: # type: ignore
return self.to_constraint(EQUAL, rhs)
def __gt__(self, other: Any) -> NotImplementedType:
@@ -2384,7 +2336,7 @@ def merge(
has_quad_expression = any(type(e) is QuadraticExpression for e in exprs)
has_linear_expression = any(type(e) is LinearExpression for e in exprs)
if cls is None:
- cls = QuadraticExpression if has_quad_expression else LinearExpression
+ cls = QuadraticExpression if has_quad_expression else LinearExpression # type: ignore
if cls is QuadraticExpression and dim == TERM_DIM and has_linear_expression:
raise ValueError(
@@ -2564,10 +2516,6 @@ def __truediv__(self, other: float | int) -> ScalarLinearExpression:
return self.__div__(other)
def __le__(self, other: int | float) -> AnonymousScalarConstraint:
- descriptor = _to_piecewise_constraint_descriptor(self, other, "<=")
- if descriptor is not None:
- return descriptor # type: ignore[return-value]
-
if not isinstance(other, int | float | np.number):
raise TypeError(
f"unsupported operand type(s) for <=: {type(self)} and {type(other)}"
@@ -2576,10 +2524,6 @@ def __le__(self, other: int | float) -> AnonymousScalarConstraint:
return constraints.AnonymousScalarConstraint(self, LESS_EQUAL, other)
def __ge__(self, other: int | float) -> AnonymousScalarConstraint:
- descriptor = _to_piecewise_constraint_descriptor(self, other, ">=")
- if descriptor is not None:
- return descriptor # type: ignore[return-value]
-
if not isinstance(other, int | float | np.number):
raise TypeError(
f"unsupported operand type(s) for >=: {type(self)} and {type(other)}"
@@ -2590,10 +2534,6 @@ def __ge__(self, other: int | float) -> AnonymousScalarConstraint:
def __eq__( # type: ignore[override]
self, other: int | float
) -> AnonymousScalarConstraint:
- descriptor = _to_piecewise_constraint_descriptor(self, other, "==")
- if descriptor is not None:
- return descriptor # type: ignore[return-value]
-
if not isinstance(other, int | float | np.number):
raise TypeError(
f"unsupported operand type(s) for ==: {type(self)} and {type(other)}"
diff --git a/linopy/piecewise.py b/linopy/piecewise.py
index 78f7be65..e06664b3 100644
--- a/linopy/piecewise.py
+++ b/linopy/piecewise.py
@@ -8,7 +8,6 @@
from __future__ import annotations
from collections.abc import Sequence
-from dataclasses import dataclass
from numbers import Real
from typing import TYPE_CHECKING, Literal, TypeAlias
@@ -22,7 +21,6 @@
HELPER_DIMS,
LP_SEG_DIM,
PWL_ACTIVE_BOUND_SUFFIX,
- PWL_AUX_SUFFIX,
PWL_BINARY_SUFFIX,
PWL_CONVEX_SUFFIX,
PWL_DELTA_SUFFIX,
@@ -31,11 +29,8 @@
PWL_INC_LINK_SUFFIX,
PWL_INC_ORDER_SUFFIX,
PWL_LAMBDA_SUFFIX,
- PWL_LP_DOMAIN_SUFFIX,
- PWL_LP_SUFFIX,
PWL_SELECT_SUFFIX,
PWL_X_LINK_SUFFIX,
- PWL_Y_LINK_SUFFIX,
SEGMENT_DIM,
)
@@ -64,6 +59,18 @@
# ---------------------------------------------------------------------------
+def _strip_nan(vals: Sequence[float] | np.ndarray) -> list[float]:
+ """Remove NaN values from a sequence."""
+ return [v for v in vals if not np.isnan(v)]
+
+
+def _rename_to_segments(da: DataArray, seg_index: np.ndarray) -> DataArray:
+ """Rename breakpoint dim to segment dim and reassign coordinates."""
+ da = da.rename({BREAKPOINT_DIM: LP_SEG_DIM})
+ da[LP_SEG_DIM] = seg_index
+ return da
+
+
def _sequence_to_array(values: Sequence[float]) -> DataArray:
arr = np.asarray(values, dtype=float)
if arr.ndim != 1:
@@ -145,7 +152,7 @@ def _dict_segments_to_array(
for key, seg_list in d.items():
arr = _segments_list_to_array(seg_list)
parts.append(arr.expand_dims({dim: [key]}))
- combined = xr.concat(parts, dim=dim)
+ combined = xr.concat(parts, dim=dim, coords="minimal")
max_bp = max(max(len(seg) for seg in sl) for sl in d.values())
max_seg = max(len(sl) for sl in d.values())
if combined.sizes[BREAKPOINT_DIM] < max_bp or combined.sizes[SEGMENT_DIM] < max_seg:
@@ -156,6 +163,59 @@ def _dict_segments_to_array(
return combined
+def _breakpoints_from_slopes(
+ slopes: BreaksLike,
+ x_points: BreaksLike,
+ y0: float | dict[str, float] | pd.Series | DataArray,
+ dim: str | None,
+) -> DataArray:
+ """Convert slopes + x_points + y0 into a breakpoint DataArray."""
+ slopes_arr = _coerce_breaks(slopes, dim)
+ xp_arr = _coerce_breaks(x_points, dim)
+
+ # 1D case: single set of breakpoints
+ if slopes_arr.ndim == 1:
+ if not isinstance(y0, Real):
+ raise TypeError("When 'slopes' is 1D, 'y0' must be a scalar float")
+ pts = slopes_to_points(list(xp_arr.values), list(slopes_arr.values), float(y0))
+ return _sequence_to_array(pts)
+
+ # Multi-dim case: per-entity slopes
+ entity_dims = [d for d in slopes_arr.dims if d != BREAKPOINT_DIM]
+ if len(entity_dims) != 1:
+ raise ValueError(
+ f"Expected exactly one entity dimension in slopes, got {entity_dims}"
+ )
+ entity_dim = str(entity_dims[0])
+ entity_keys = slopes_arr.coords[entity_dim].values
+
+ # Resolve y0 per entity
+ if isinstance(y0, Real):
+ y0_map: dict[str, float] = {str(k): float(y0) for k in entity_keys}
+ elif isinstance(y0, dict):
+ y0_map = {str(k): float(y0[k]) for k in entity_keys}
+ elif isinstance(y0, pd.Series):
+ y0_map = {str(k): float(y0[k]) for k in entity_keys}
+ elif isinstance(y0, DataArray):
+ y0_map = {str(k): float(y0.sel({entity_dim: k}).item()) for k in entity_keys}
+ else:
+ raise TypeError(
+ f"'y0' must be a float, Series, DataArray, or dict, got {type(y0)}"
+ )
+
+ computed: dict[str, Sequence[float]] = {}
+ for key in entity_keys:
+ sk = str(key)
+ sl = _strip_nan(slopes_arr.sel({entity_dim: key}).values)
+ if entity_dim in xp_arr.dims:
+ xp = _strip_nan(xp_arr.sel({entity_dim: key}).values)
+ else:
+ xp = _strip_nan(xp_arr.values)
+ computed[sk] = slopes_to_points(xp, sl, y0_map[sk])
+
+ return _dict_to_array(computed, entity_dim)
+
+
# ---------------------------------------------------------------------------
# Public factory functions
# ---------------------------------------------------------------------------
@@ -245,64 +305,7 @@ def breakpoints(
if slopes is not None:
if x_points is None or y0 is None:
raise ValueError("'slopes' requires both 'x_points' and 'y0'")
-
- # Slopes mode: convert to points, then fall through to coerce
- if slopes is not None:
- if x_points is None or y0 is None:
- raise ValueError("'slopes' requires both 'x_points' and 'y0'")
- slopes_arr = _coerce_breaks(slopes, dim)
- xp_arr = _coerce_breaks(x_points, dim)
-
- # 1D case: single set of breakpoints
- if slopes_arr.ndim == 1:
- if not isinstance(y0, Real):
- raise TypeError("When 'slopes' is 1D, 'y0' must be a scalar float")
- pts = slopes_to_points(
- list(xp_arr.values), list(slopes_arr.values), float(y0)
- )
- return _sequence_to_array(pts)
-
- # Multi-dim case: per-entity slopes
- # Identify the entity dimension (not BREAKPOINT_DIM)
- entity_dims = [d for d in slopes_arr.dims if d != BREAKPOINT_DIM]
- if len(entity_dims) != 1:
- raise ValueError(
- f"Expected exactly one entity dimension in slopes, got {entity_dims}"
- )
- entity_dim = str(entity_dims[0])
- entity_keys = slopes_arr.coords[entity_dim].values
-
- # Resolve y0 per entity
- if isinstance(y0, Real):
- y0_map: dict[str, float] = {str(k): float(y0) for k in entity_keys}
- elif isinstance(y0, dict):
- y0_map = {str(k): float(y0[k]) for k in entity_keys}
- elif isinstance(y0, pd.Series):
- y0_map = {str(k): float(y0[k]) for k in entity_keys}
- elif isinstance(y0, DataArray):
- y0_map = {
- str(k): float(y0.sel({entity_dim: k}).item()) for k in entity_keys
- }
- else:
- raise TypeError(
- f"'y0' must be a float, Series, DataArray, or dict, got {type(y0)}"
- )
-
- # Compute points per entity
- computed: dict[str, Sequence[float]] = {}
- for key in entity_keys:
- sk = str(key)
- sl = list(slopes_arr.sel({entity_dim: key}).values)
- # Remove trailing NaN from slopes
- sl = [v for v in sl if not np.isnan(v)]
- if entity_dim in xp_arr.dims:
- xp = list(xp_arr.sel({entity_dim: key}).values)
- xp = [v for v in xp if not np.isnan(v)]
- else:
- xp = [v for v in xp_arr.values if not np.isnan(v)]
- computed[sk] = slopes_to_points(xp, sl, y0_map[sk])
-
- return _dict_to_array(computed, entity_dim)
+ return _breakpoints_from_slopes(slopes, x_points, y0, dim)
# Points mode
if values is None:
@@ -363,165 +366,112 @@ def segments(
return _coerce_segments(values, dim)
-# ---------------------------------------------------------------------------
-# Piecewise expression and descriptor types
-# ---------------------------------------------------------------------------
+def tangent_lines(
+ x: LinExprLike,
+ x_points: BreaksLike,
+ y_points: BreaksLike,
+) -> LinearExpression:
+ r"""
+ Compute tangent-line expressions for a piecewise linear function.
+ Returns a :class:`~linopy.expressions.LinearExpression` with an extra
+ segment dimension. Each element along the segment dimension is the
+ tangent line of one segment: :math:`m_k \cdot x + c_k`.
-class PiecewiseExpression:
- """
- Lazy descriptor representing a piecewise linear function of an expression.
+ Use the result in a regular constraint to create an upper or lower
+ bound:
- Created by :func:`piecewise`. Supports comparison operators so that
- ``piecewise(x, ...) >= y`` produces a
- :class:`PiecewiseConstraintDescriptor`.
- """
+ .. code-block:: python
- __slots__ = ("active", "disjunctive", "expr", "x_points", "y_points")
+ t = tangent_lines(power, x_pts, y_pts)
+ m.add_constraints(fuel <= t) # upper bound (concave f)
+ m.add_constraints(fuel >= t) # lower bound (convex f)
- def __init__(
- self,
- expr: LinExprLike,
- x_points: DataArray,
- y_points: DataArray,
- disjunctive: bool,
- active: LinExprLike | None = None,
- ) -> None:
- self.expr = expr
- self.x_points = x_points
- self.y_points = y_points
- self.disjunctive = disjunctive
- self.active = active
+ No auxiliary variables are created — the result is purely linear.
- # y <= pw → Python tries y.__le__(pw) → NotImplemented → pw.__ge__(y)
- def __ge__(self, other: LinExprLike) -> PiecewiseConstraintDescriptor:
- return PiecewiseConstraintDescriptor(lhs=other, sign="<=", piecewise_func=self)
+ Parameters
+ ----------
+ x : Variable or LinearExpression
+ The input expression.
+ x_points : BreaksLike
+ Breakpoint x-coordinates (must be strictly increasing).
+ y_points : BreaksLike
+ Breakpoint y-coordinates.
- # y >= pw → Python tries y.__ge__(pw) → NotImplemented → pw.__le__(y)
- def __le__(self, other: LinExprLike) -> PiecewiseConstraintDescriptor:
- return PiecewiseConstraintDescriptor(lhs=other, sign=">=", piecewise_func=self)
+ Returns
+ -------
+ LinearExpression
+ Expression with an additional ``_breakpoint_seg`` dimension
+ (one entry per segment).
+ """
+ from linopy.expressions import LinearExpression as LinExpr
+ from linopy.variables import Variable
- # y == pw → Python tries y.__eq__(pw) → NotImplemented → pw.__eq__(y)
- def __eq__(self, other: object) -> PiecewiseConstraintDescriptor: # type: ignore[override]
- from linopy.expressions import LinearExpression
- from linopy.variables import Variable
+ x_points = _coerce_breaks(x_points)
+ y_points = _coerce_breaks(y_points)
- if not isinstance(other, Variable | LinearExpression):
- return NotImplemented
- return PiecewiseConstraintDescriptor(lhs=other, sign="==", piecewise_func=self)
+ dx = x_points.diff(BREAKPOINT_DIM)
+ dy = y_points.diff(BREAKPOINT_DIM)
+ seg_index = np.arange(dx.sizes[BREAKPOINT_DIM])
+
+ slopes = _rename_to_segments(dy / dx, seg_index)
+ x_base = _rename_to_segments(
+ x_points.isel({BREAKPOINT_DIM: slice(None, -1)}), seg_index
+ )
+ y_base = _rename_to_segments(
+ y_points.isel({BREAKPOINT_DIM: slice(None, -1)}), seg_index
+ )
+ intercepts = y_base - slopes * x_base
-@dataclass
-class PiecewiseConstraintDescriptor:
- """Holds all information needed to add a piecewise constraint to a model."""
+ if not isinstance(x, Variable | LinExpr):
+ raise TypeError(f"x must be a Variable or LinearExpression, got {type(x)}")
- lhs: LinExprLike
- sign: str # "<=", ">=", "=="
- piecewise_func: PiecewiseExpression
+ return slopes * _to_linexpr(x) + intercepts
-def _detect_disjunctive(x_points: DataArray, y_points: DataArray) -> bool:
+# ---------------------------------------------------------------------------
+# Internal validation and utility functions
+# ---------------------------------------------------------------------------
+
+
+def _validate_breakpoint_shapes(bp_a: DataArray, bp_b: DataArray) -> bool:
"""
- Detect whether point arrays represent a disjunctive formulation.
+ Validate that two breakpoint arrays have compatible shapes.
- Both ``x_points`` and ``y_points`` **must** use the well-known dimension
- names ``BREAKPOINT_DIM`` and, for disjunctive formulations,
- ``SEGMENT_DIM``. Use the :func:`breakpoints` / :func:`segments` factory
- helpers to build arrays with the correct dimension names.
+ Returns whether the formulation is disjunctive (has segment dimension).
"""
- x_has_bp = BREAKPOINT_DIM in x_points.dims
- y_has_bp = BREAKPOINT_DIM in y_points.dims
- if not x_has_bp and not y_has_bp:
- raise ValueError(
- "x_points and y_points must have a breakpoint dimension. "
- f"Got x_points dims {list(x_points.dims)} and y_points dims "
- f"{list(y_points.dims)}. Use the breakpoints() or segments() "
- f"factory to create correctly-dimensioned arrays."
- )
- if not x_has_bp:
+ if BREAKPOINT_DIM not in bp_a.dims:
raise ValueError(
- "x_points is missing the breakpoint dimension, "
- f"got dims {list(x_points.dims)}. "
+ f"Breakpoints are missing the '{BREAKPOINT_DIM}' dimension, "
+ f"got dims {list(bp_a.dims)}. "
"Use the breakpoints() or segments() factory."
)
- if not y_has_bp:
+ if BREAKPOINT_DIM not in bp_b.dims:
raise ValueError(
- "y_points is missing the breakpoint dimension, "
- f"got dims {list(y_points.dims)}. "
+ f"Breakpoints are missing the '{BREAKPOINT_DIM}' dimension, "
+ f"got dims {list(bp_b.dims)}. "
"Use the breakpoints() or segments() factory."
)
- x_has_seg = SEGMENT_DIM in x_points.dims
- y_has_seg = SEGMENT_DIM in y_points.dims
- if x_has_seg != y_has_seg:
+ if bp_a.sizes[BREAKPOINT_DIM] != bp_b.sizes[BREAKPOINT_DIM]:
raise ValueError(
- "If one of x_points/y_points has a segment dimension, "
- f"both must. x_points dims: {list(x_points.dims)}, "
- f"y_points dims: {list(y_points.dims)}."
+ f"Breakpoints must have same size along '{BREAKPOINT_DIM}', "
+ f"got {bp_a.sizes[BREAKPOINT_DIM]} and "
+ f"{bp_b.sizes[BREAKPOINT_DIM]}"
)
- return x_has_seg
-
-
-def piecewise(
- expr: LinExprLike,
- x_points: BreaksLike,
- y_points: BreaksLike,
- active: LinExprLike | None = None,
-) -> PiecewiseExpression:
- """
- Create a piecewise linear function descriptor.
-
- Parameters
- ----------
- expr : Variable or LinearExpression
- The "x" side expression.
- x_points : BreaksLike
- Breakpoint x-coordinates.
- y_points : BreaksLike
- Breakpoint y-coordinates.
- active : Variable or LinearExpression, optional
- Binary variable that scales the piecewise function. When
- ``active=0``, all auxiliary variables are forced to zero, which
- in turn forces the reconstructed x and y to zero. When
- ``active=1``, the normal piecewise domain ``[x₀, xₙ]`` is
- active. This is the only behavior the linear formulation
- supports — selectively *relaxing* the constraint (letting x and
- y float freely when off) would require big-M or indicator
- constraints.
-
- Returns
- -------
- PiecewiseExpression
- """
- if not isinstance(x_points, DataArray):
- x_points = _coerce_breaks(x_points)
- if not isinstance(y_points, DataArray):
- y_points = _coerce_breaks(y_points)
-
- disjunctive = _detect_disjunctive(x_points, y_points)
-
- # Validate compatible shapes along breakpoint dimension
- if x_points.sizes[BREAKPOINT_DIM] != y_points.sizes[BREAKPOINT_DIM]:
+ a_has_seg = SEGMENT_DIM in bp_a.dims
+ b_has_seg = SEGMENT_DIM in bp_b.dims
+ if a_has_seg != b_has_seg:
raise ValueError(
- f"x_points and y_points must have same size along '{BREAKPOINT_DIM}', "
- f"got {x_points.sizes[BREAKPOINT_DIM]} and "
- f"{y_points.sizes[BREAKPOINT_DIM]}"
+ "If one breakpoint array has a segment dimension, "
+ f"both must. Got dims: {list(bp_a.dims)} and {list(bp_b.dims)}."
)
+ if a_has_seg and bp_a.sizes[SEGMENT_DIM] != bp_b.sizes[SEGMENT_DIM]:
+ raise ValueError(f"Breakpoints must have same size along '{SEGMENT_DIM}'")
- # Validate compatible shapes along segment dimension
- if disjunctive:
- if x_points.sizes[SEGMENT_DIM] != y_points.sizes[SEGMENT_DIM]:
- raise ValueError(
- f"x_points and y_points must have same size along '{SEGMENT_DIM}'"
- )
-
- return PiecewiseExpression(expr, x_points, y_points, disjunctive, active)
-
-
-# ---------------------------------------------------------------------------
-# Internal validation and utility functions
-# ---------------------------------------------------------------------------
+ return a_has_seg
def _validate_numeric_breakpoint_coords(bp: DataArray) -> None:
@@ -544,15 +494,6 @@ def _check_strict_monotonicity(bp: DataArray) -> bool:
return bool(monotonic.all())
-def _check_strict_increasing(bp: DataArray) -> bool:
- """Check if breakpoints are strictly increasing along BREAKPOINT_DIM."""
- diffs = bp.diff(BREAKPOINT_DIM)
- pos = (diffs > 0) | diffs.isnull()
- has_non_nan = (~diffs.isnull()).any(BREAKPOINT_DIM)
- increasing = pos.all(BREAKPOINT_DIM) & has_non_nan
- return bool(increasing.all())
-
-
def _has_trailing_nan_only(bp: DataArray) -> bool:
"""Check that NaN values only appear as trailing entries along BREAKPOINT_DIM."""
valid = ~bp.isnull()
@@ -569,8 +510,11 @@ def _to_linexpr(expr: LinExprLike) -> LinearExpression:
return expr.to_linexpr()
-def _extra_coords(points: DataArray, *exclude_dims: str | None) -> list[pd.Index]:
- excluded = {d for d in exclude_dims if d is not None}
+def _var_coords_from(
+ points: DataArray, exclude: set[str] | None = None
+) -> list[pd.Index]:
+ """Extract pd.Index coords from points, excluding specified dimensions."""
+ excluded = exclude or set()
return [
pd.Index(points.coords[d].values, name=d)
for d in points.dims
@@ -588,9 +532,10 @@ def _broadcast_points(
if disjunctive:
skip.add(SEGMENT_DIM)
+ lin_exprs = [_to_linexpr(e) for e in exprs]
+
target_dims: set[str] = set()
- for e in exprs:
- le = _to_linexpr(e)
+ for le in lin_exprs:
target_dims.update(str(d) for d in le.coord_dims)
missing = target_dims - skip - {str(d) for d in points.dims}
@@ -599,8 +544,7 @@ def _broadcast_points(
expand_map: dict[str, list] = {}
for d in missing:
- for e in exprs:
- le = _to_linexpr(e)
+ for le in lin_exprs:
if d in le.coords:
expand_map[str(d)] = list(le.coords[d].values)
break
@@ -610,613 +554,417 @@ def _broadcast_points(
return points
-def _compute_combined_mask(
- x_points: DataArray,
- y_points: DataArray,
- skip_nan_check: bool,
-) -> DataArray | None:
- if skip_nan_check:
- if bool(x_points.isnull().any()) or bool(y_points.isnull().any()):
- raise ValueError(
- "skip_nan_check=True but breakpoints contain NaN. "
- "Either remove NaN values or set skip_nan_check=False."
- )
- return None
- return ~(x_points.isnull() | y_points.isnull())
+# ---------------------------------------------------------------------------
+# Main entry point
+# ---------------------------------------------------------------------------
-def _detect_convexity(
- x_points: DataArray,
- y_points: DataArray,
-) -> Literal["convex", "concave", "linear", "mixed"]:
- """
- Detect convexity of the piecewise function.
+def add_piecewise_constraints(
+ model: Model,
+ *pairs: tuple[LinExprLike, BreaksLike],
+ method: Literal["sos2", "incremental", "auto"] = "auto",
+ active: LinExprLike | None = None,
+ name: str | None = None,
+) -> Constraint:
+ r"""
+ Add piecewise linear equality 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.
+
+ Example — 2 variables::
+
+ m.add_piecewise_constraints(
+ (power, [0, 30, 60, 100]),
+ (fuel, [0, 36, 84, 170]),
+ )
+
+ Example — 3 variables (CHP plant)::
+
+ m.add_piecewise_constraints(
+ (power, [0, 30, 60, 100]),
+ (fuel, [0, 40, 85, 160]),
+ (heat, [0, 25, 55, 95]),
+ )
+
+ For inequality constraints (:math:`y \le f(x)` or
+ :math:`y \ge f(x)`), use :func:`tangent_lines` with regular
+ ``add_constraints`` instead.
- Requires strictly increasing x breakpoints and computes slopes and
- second differences in the given order.
+ Parameters
+ ----------
+ *pairs : tuple of (expression, breakpoints)
+ Each pair links an expression (Variable or LinearExpression)
+ to its breakpoint values (list, DataArray, etc.). At least
+ two pairs are required.
+ method : {"auto", "sos2", "incremental"}, default "auto"
+ Formulation method.
+ active : Variable or LinearExpression, optional
+ Binary variable that gates the piecewise function. When
+ ``active=0``, all auxiliary variables are forced to zero.
+ name : str, optional
+ Base name for generated variables/constraints.
+
+ Returns
+ -------
+ Constraint
"""
- if not _check_strict_increasing(x_points):
+ if method not in ("sos2", "incremental", "auto"):
raise ValueError(
- "Convexity detection requires strictly increasing x_points. "
- "Pass breakpoints in increasing x-order or use method='sos2'."
+ f"method must be 'sos2', 'incremental', or 'auto', got '{method}'"
)
- dx = x_points.diff(BREAKPOINT_DIM)
- dy = y_points.diff(BREAKPOINT_DIM)
+ if len(pairs) < 2:
+ raise TypeError(
+ "add_piecewise_constraints() requires at least 2 "
+ "(expression, breakpoints) pairs."
+ )
- valid = ~(dx.isnull() | dy.isnull() | (dx == 0))
- slopes = dy / dx
+ for i, pair in enumerate(pairs):
+ if not isinstance(pair, tuple) or len(pair) != 2:
+ raise TypeError(
+ f"Argument {i + 1} must be a (expression, breakpoints) tuple, "
+ f"got {type(pair)}."
+ )
- if slopes.sizes[BREAKPOINT_DIM] < 2:
- return "linear"
+ # Coerce all breakpoints. Drop scalar coordinates (e.g. left over
+ # from bp.sel(var="power")) so they don't conflict when stacking.
+ coerced: list[tuple[LinExprLike, DataArray]] = []
+ for expr, bp in pairs:
+ if not isinstance(bp, DataArray):
+ bp = _coerce_breaks(bp)
+ scalar_coords = [c for c in bp.coords if c not in bp.dims]
+ if scalar_coords:
+ bp = bp.drop_vars(scalar_coords)
+ coerced.append((expr, bp))
+
+ # Check for disjunctive (segment dimension) on first pair
+ first_bp = coerced[0][1]
+ disjunctive = SEGMENT_DIM in first_bp.dims
+
+ # 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])
+
+ # Broadcast all breakpoints to match all expression dimensions
+ all_exprs = [expr for expr, _ in coerced]
+ bp_list = [
+ _broadcast_points(bp, *all_exprs, disjunctive=disjunctive) for _, bp in coerced
+ ]
- slope_diffs = slopes.diff(BREAKPOINT_DIM)
+ # 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
- valid_diffs = valid.isel({BREAKPOINT_DIM: slice(None, -1)})
- valid_diffs_hi = valid.isel({BREAKPOINT_DIM: slice(1, None)})
- valid_diffs_combined = valid_diffs.values & valid_diffs_hi.values
+ # Name
+ if name is None:
+ name = f"pwl{model._pwlCounter}"
+ model._pwlCounter += 1
- sd_values = slope_diffs.values
- if valid_diffs_combined.size == 0 or not valid_diffs_combined.any():
- return "linear"
+ # Build link dimension coordinates from variable names
+ from linopy.variables import Variable
- valid_sd = sd_values[valid_diffs_combined]
- all_nonneg = bool(np.all(valid_sd >= -1e-10))
- all_nonpos = bool(np.all(valid_sd <= 1e-10))
+ link_coords: list[str] = []
+ for i, expr in enumerate(all_exprs):
+ if isinstance(expr, Variable) and expr.name:
+ link_coords.append(expr.name)
+ else:
+ link_coords.append(str(i))
- if all_nonneg and all_nonpos:
- return "linear"
- if all_nonneg:
- return "convex"
- if all_nonpos:
- return "concave"
- return "mixed"
+ # Convert expressions to LinearExpressions
+ lin_exprs = [_to_linexpr(expr) for expr in all_exprs]
+ active_expr = _to_linexpr(active) if active is not None else None
+ if disjunctive:
+ if method == "incremental":
+ raise ValueError(
+ "Incremental method is not supported for disjunctive constraints"
+ )
+ return _add_disjunctive(
+ model,
+ name,
+ lin_exprs,
+ bp_list,
+ link_coords,
+ bp_mask,
+ active_expr,
+ )
-# ---------------------------------------------------------------------------
-# Internal formulation functions
-# ---------------------------------------------------------------------------
+ # Continuous: stack into N-variable formulation
+ return _add_continuous(
+ model,
+ name,
+ lin_exprs,
+ bp_list,
+ link_coords,
+ bp_mask,
+ method,
+ active_expr,
+ )
+
+
+def _stack_along_link(
+ items: Sequence[DataArray | xr.Dataset],
+ link_coords: list[str],
+ link_dim: str,
+) -> DataArray:
+ """Expand and concatenate DataArrays/Datasets along a new link dimension."""
+ expanded = [
+ item.expand_dims({link_dim: [c]}) for item, c in zip(items, link_coords)
+ ]
+ return xr.concat(expanded, dim=link_dim, coords="minimal") # type: ignore
-def _add_pwl_lp(
+def _add_continuous(
model: Model,
name: str,
- x_expr: LinearExpression,
- y_expr: LinearExpression,
- sign: str,
- x_points: DataArray,
- y_points: DataArray,
+ lin_exprs: list[LinearExpression],
+ bp_list: list[DataArray],
+ link_coords: list[str],
+ bp_mask: DataArray | None,
+ method: str,
+ active: LinearExpression | None = None,
) -> Constraint:
- """Add pure LP tangent-line constraints."""
- dx = x_points.diff(BREAKPOINT_DIM)
- dy = y_points.diff(BREAKPOINT_DIM)
- slopes = dy / dx
+ """Dispatch continuous piecewise equality to SOS2 or incremental."""
+ from linopy.expressions import LinearExpression
- slopes = slopes.rename({BREAKPOINT_DIM: LP_SEG_DIM})
- n_seg = slopes.sizes[LP_SEG_DIM]
- slopes[LP_SEG_DIM] = np.arange(n_seg)
+ link_dim = "_pwl_var"
+ stacked_bp = _stack_along_link(bp_list, link_coords, link_dim)
- x_base = x_points.isel({BREAKPOINT_DIM: slice(None, -1)})
- y_base = y_points.isel({BREAKPOINT_DIM: slice(None, -1)})
- x_base = x_base.rename({BREAKPOINT_DIM: LP_SEG_DIM})
- y_base = y_base.rename({BREAKPOINT_DIM: LP_SEG_DIM})
- x_base[LP_SEG_DIM] = np.arange(n_seg)
- y_base[LP_SEG_DIM] = np.arange(n_seg)
+ # Pre-compute properties used by multiple branches
+ trailing_nan_only = _has_trailing_nan_only(stacked_bp)
- rhs = y_base - slopes * x_base
- lhs = y_expr - slopes * x_expr
+ # Auto-detect method
+ if method in ("incremental", "auto"):
+ is_monotonic = _check_strict_monotonicity(stacked_bp)
+ if method == "auto":
+ method = "incremental" if (is_monotonic and trailing_nan_only) else "sos2"
+ elif not is_monotonic:
+ raise ValueError(
+ "Incremental method requires strictly monotonic breakpoints."
+ )
+ if method == "incremental" and not trailing_nan_only:
+ raise ValueError(
+ "Incremental method does not support non-trailing NaN breakpoints."
+ )
- if sign == "<=":
- con = model.add_constraints(lhs <= rhs, name=f"{name}{PWL_LP_SUFFIX}")
- else:
- con = model.add_constraints(lhs >= rhs, name=f"{name}{PWL_LP_SUFFIX}")
+ if method == "sos2":
+ _validate_numeric_breakpoint_coords(stacked_bp)
+ if not trailing_nan_only:
+ raise ValueError(
+ "SOS2 method does not support non-trailing NaN breakpoints."
+ )
- # Domain bound constraints to keep x within [x_min, x_max]
- x_lo = x_points.min(dim=BREAKPOINT_DIM)
- x_hi = x_points.max(dim=BREAKPOINT_DIM)
- model.add_constraints(x_expr >= x_lo, name=f"{name}{PWL_LP_DOMAIN_SUFFIX}_lo")
- model.add_constraints(x_expr <= x_hi, name=f"{name}{PWL_LP_DOMAIN_SUFFIX}_hi")
+ # Stack expressions along the link dimension
+ stacked_data = _stack_along_link([e.data for e in lin_exprs], link_coords, link_dim)
+ target_expr = LinearExpression(stacked_data, model)
- return con
+ # Compute stacked mask
+ stacked_mask = None
+ if bp_mask is not None:
+ stacked_mask = _stack_along_link(
+ [bp_mask] * len(link_coords), link_coords, link_dim
+ )
+ rhs = active if active is not None else 1
-def _add_pwl_sos2_core(
+ if method == "sos2":
+ return _add_sos2(
+ model,
+ name,
+ target_expr,
+ stacked_bp,
+ stacked_mask,
+ link_dim,
+ rhs,
+ )
+ else:
+ return _add_incremental(
+ model,
+ name,
+ target_expr,
+ stacked_bp,
+ stacked_mask,
+ link_dim,
+ rhs,
+ active,
+ )
+
+
+def _add_sos2(
model: Model,
name: str,
- x_expr: LinearExpression,
target_expr: LinearExpression,
- x_points: DataArray,
- y_points: DataArray,
- lambda_mask: DataArray | None,
- active: LinearExpression | None = None,
+ stacked_bp: DataArray,
+ stacked_mask: DataArray | None,
+ link_dim: str,
+ rhs: LinearExpression | int,
) -> Constraint:
- """
- Core SOS2 formulation linking x_expr and target_expr via breakpoints.
-
- Creates lambda variables, SOS2 constraint, convexity constraint,
- and linking constraints for both x and target.
-
- When ``active`` is provided, the convexity constraint becomes
- ``sum(lambda) == active`` instead of ``== 1``, forcing all lambda
- (and thus x, y) to zero when ``active=0``.
- """
- extra = _extra_coords(x_points, BREAKPOINT_DIM)
- lambda_coords = extra + [
- pd.Index(x_points.coords[BREAKPOINT_DIM].values, name=BREAKPOINT_DIM)
- ]
+ """SOS2 formulation for N-variable continuous piecewise equality."""
+ dim = BREAKPOINT_DIM
+ extra = _var_coords_from(stacked_bp, exclude={dim, link_dim})
+ lambda_mask = stacked_mask.any(dim=link_dim) if stacked_mask is not None else None
+ lambda_coords = extra + [pd.Index(stacked_bp.coords[dim].values, name=dim)]
lambda_name = f"{name}{PWL_LAMBDA_SUFFIX}"
convex_name = f"{name}{PWL_CONVEX_SUFFIX}"
- x_link_name = f"{name}{PWL_X_LINK_SUFFIX}"
- y_link_name = f"{name}{PWL_Y_LINK_SUFFIX}"
+ link_name = f"{name}{PWL_X_LINK_SUFFIX}"
lambda_var = model.add_variables(
lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=lambda_mask
)
+ model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=dim)
+ model.add_constraints(lambda_var.sum(dim=dim) == rhs, name=convex_name)
- model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=BREAKPOINT_DIM)
-
- # Convexity constraint: sum(lambda) == 1 or sum(lambda) == active
- rhs = active if active is not None else 1
- convex_con = model.add_constraints(
- lambda_var.sum(dim=BREAKPOINT_DIM) == rhs, name=convex_name
- )
-
- x_weighted = (lambda_var * x_points).sum(dim=BREAKPOINT_DIM)
- model.add_constraints(x_expr == x_weighted, name=x_link_name)
+ weighted_sum = (lambda_var * stacked_bp).sum(dim=dim)
+ return model.add_constraints(target_expr == weighted_sum, name=link_name)
- y_weighted = (lambda_var * y_points).sum(dim=BREAKPOINT_DIM)
- model.add_constraints(target_expr == y_weighted, name=y_link_name)
- return convex_con
-
-
-def _add_pwl_incremental_core(
+def _add_incremental(
model: Model,
name: str,
- x_expr: LinearExpression,
target_expr: LinearExpression,
- x_points: DataArray,
- y_points: DataArray,
- bp_mask: DataArray | None,
- active: LinearExpression | None = None,
+ stacked_bp: DataArray,
+ stacked_mask: DataArray | None,
+ link_dim: str,
+ rhs: LinearExpression | int,
+ active: LinearExpression | None,
) -> Constraint:
- """
- Core incremental formulation linking x_expr and target_expr.
-
- Creates delta variables, fill-order constraints, and x/target link constraints.
+ """Incremental formulation for N-variable continuous piecewise equality."""
+ dim = BREAKPOINT_DIM
+ extra = _var_coords_from(stacked_bp, exclude={dim, link_dim})
- When ``active`` is provided, delta bounds are tightened to
- ``δ_i ≤ active`` and base terms become ``x₀ * active``,
- ``y₀ * active``, forcing x and y to zero when ``active=0``.
- """
delta_name = f"{name}{PWL_DELTA_SUFFIX}"
fill_name = f"{name}{PWL_FILL_SUFFIX}"
- x_link_name = f"{name}{PWL_X_LINK_SUFFIX}"
- y_link_name = f"{name}{PWL_Y_LINK_SUFFIX}"
+ link_name = f"{name}{PWL_X_LINK_SUFFIX}"
+ inc_binary_name = f"{name}{PWL_INC_BINARY_SUFFIX}"
+ inc_link_name = f"{name}{PWL_INC_LINK_SUFFIX}"
+ inc_order_name = f"{name}{PWL_INC_ORDER_SUFFIX}"
- n_segments = x_points.sizes[BREAKPOINT_DIM] - 1
- seg_index = pd.Index(range(n_segments), name=LP_SEG_DIM)
- extra = _extra_coords(x_points, BREAKPOINT_DIM)
+ n_segments = stacked_bp.sizes[dim] - 1
+ seg_dim = f"{dim}_seg"
+ seg_index = pd.Index(range(n_segments), name=seg_dim)
delta_coords = extra + [seg_index]
- x_steps = x_points.diff(BREAKPOINT_DIM).rename({BREAKPOINT_DIM: LP_SEG_DIM})
- x_steps[LP_SEG_DIM] = seg_index
- y_steps = y_points.diff(BREAKPOINT_DIM).rename({BREAKPOINT_DIM: LP_SEG_DIM})
- y_steps[LP_SEG_DIM] = seg_index
+ steps = stacked_bp.diff(dim).rename({dim: seg_dim})
+ steps[seg_dim] = seg_index
- if bp_mask is not None:
- mask_lo = bp_mask.isel({BREAKPOINT_DIM: slice(None, -1)}).rename(
- {BREAKPOINT_DIM: LP_SEG_DIM}
- )
- mask_hi = bp_mask.isel({BREAKPOINT_DIM: slice(1, None)}).rename(
- {BREAKPOINT_DIM: LP_SEG_DIM}
- )
- mask_lo[LP_SEG_DIM] = seg_index
- mask_hi[LP_SEG_DIM] = seg_index
+ if stacked_mask is not None:
+ bp_mask_agg = stacked_mask.all(dim=link_dim)
+ mask_lo = bp_mask_agg.isel({dim: slice(None, -1)}).rename({dim: seg_dim})
+ mask_hi = bp_mask_agg.isel({dim: slice(1, None)}).rename({dim: seg_dim})
+ mask_lo[seg_dim] = seg_index
+ mask_hi[seg_dim] = seg_index
delta_mask: DataArray | None = mask_lo & mask_hi
else:
delta_mask = None
- # When active is provided, upper bound is active (binary) instead of 1
- delta_upper = 1
delta_var = model.add_variables(
- lower=0,
- upper=delta_upper,
- coords=delta_coords,
- name=delta_name,
- mask=delta_mask,
+ lower=0, upper=1, coords=delta_coords, name=delta_name, mask=delta_mask
)
if active is not None:
- # Tighten delta bounds: δ_i ≤ active
active_bound_name = f"{name}{PWL_ACTIVE_BOUND_SUFFIX}"
model.add_constraints(delta_var <= active, name=active_bound_name)
- # Binary indicator variables: y_i for each segment
- inc_binary_name = f"{name}{PWL_INC_BINARY_SUFFIX}"
- inc_link_name = f"{name}{PWL_INC_LINK_SUFFIX}"
- inc_order_name = f"{name}{PWL_INC_ORDER_SUFFIX}"
-
binary_var = model.add_variables(
binary=True, coords=delta_coords, name=inc_binary_name, mask=delta_mask
)
-
- # Link constraints: δ_i ≤ y_i for all segments
model.add_constraints(delta_var <= binary_var, name=inc_link_name)
- # Order constraints: y_{i+1} ≤ δ_i for i = 0..n-2
- fill_con: Constraint | None = None
if n_segments >= 2:
- delta_lo = delta_var.isel({LP_SEG_DIM: slice(None, -1)}, drop=True)
- delta_hi = delta_var.isel({LP_SEG_DIM: slice(1, None)}, drop=True)
- # Keep existing fill constraint as LP relaxation tightener
- fill_con = model.add_constraints(delta_hi <= delta_lo, name=fill_name)
+ delta_lo = delta_var.isel({seg_dim: slice(None, -1)}, drop=True)
+ delta_hi = delta_var.isel({seg_dim: slice(1, None)}, drop=True)
+ model.add_constraints(delta_hi <= delta_lo, name=fill_name)
- binary_hi = binary_var.isel({LP_SEG_DIM: slice(1, None)}, drop=True)
+ binary_hi = binary_var.isel({seg_dim: slice(1, None)}, drop=True)
model.add_constraints(binary_hi <= delta_lo, name=inc_order_name)
- x0 = x_points.isel({BREAKPOINT_DIM: 0})
- y0 = y_points.isel({BREAKPOINT_DIM: 0})
-
- # When active is provided, multiply base terms by active
- x_base: DataArray | LinearExpression = x0
- y_base: DataArray | LinearExpression = y0
+ bp0 = stacked_bp.isel({dim: 0})
+ bp0_term: DataArray | LinearExpression = bp0
if active is not None:
- x_base = x0 * active
- y_base = y0 * active
+ bp0_term = bp0 * active
+ weighted_sum = (delta_var * steps).sum(dim=seg_dim) + bp0_term
+ return model.add_constraints(target_expr == weighted_sum, name=link_name)
- x_weighted = (delta_var * x_steps).sum(dim=LP_SEG_DIM) + x_base
- model.add_constraints(x_expr == x_weighted, name=x_link_name)
- y_weighted = (delta_var * y_steps).sum(dim=LP_SEG_DIM) + y_base
- model.add_constraints(target_expr == y_weighted, name=y_link_name)
-
- return fill_con if fill_con is not None else model.constraints[y_link_name]
-
-
-def _add_dpwl_sos2_core(
+def _add_disjunctive(
model: Model,
name: str,
- x_expr: LinearExpression,
- target_expr: LinearExpression,
- x_points: DataArray,
- y_points: DataArray,
- lambda_mask: DataArray | None,
+ lin_exprs: list[LinearExpression],
+ bp_list: list[DataArray],
+ link_coords: list[str],
+ bp_mask: DataArray | None,
active: LinearExpression | None = None,
) -> Constraint:
- """
- Core disjunctive SOS2 formulation with separate x/y points.
+ """Disjunctive SOS2 formulation for N-variable piecewise equality."""
+ from linopy.expressions import LinearExpression
- When ``active`` is provided, the segment selection becomes
- ``sum(z_k) == active`` instead of ``== 1``, forcing all segment
- binaries, lambdas, and thus x and y to zero when ``active=0``.
- """
- binary_name = f"{name}{PWL_BINARY_SUFFIX}"
- select_name = f"{name}{PWL_SELECT_SUFFIX}"
- lambda_name = f"{name}{PWL_LAMBDA_SUFFIX}"
- convex_name = f"{name}{PWL_CONVEX_SUFFIX}"
- x_link_name = f"{name}{PWL_X_LINK_SUFFIX}"
- y_link_name = f"{name}{PWL_Y_LINK_SUFFIX}"
+ link_dim = "_pwl_var"
+ stacked_bp = _stack_along_link(bp_list, link_coords, link_dim)
- extra = _extra_coords(x_points, BREAKPOINT_DIM, SEGMENT_DIM)
+ _validate_numeric_breakpoint_coords(stacked_bp)
+ if not _has_trailing_nan_only(stacked_bp):
+ raise ValueError(
+ "Disjunctive SOS2 does not support non-trailing NaN breakpoints. "
+ "NaN values must only appear at the end of the breakpoint sequence."
+ )
+
+ # Stack expressions along link dimension
+ stacked_data = _stack_along_link([e.data for e in lin_exprs], link_coords, link_dim)
+ target_expr = LinearExpression(stacked_data, model)
+
+ # Compute stacked mask
+ stacked_mask = None
+ if bp_mask is not None:
+ stacked_mask = _stack_along_link(
+ [bp_mask] * len(link_coords), link_coords, link_dim
+ )
+
+ dim = BREAKPOINT_DIM
+ extra = _var_coords_from(stacked_bp, exclude={dim, SEGMENT_DIM, link_dim})
lambda_coords = extra + [
- pd.Index(x_points.coords[SEGMENT_DIM].values, name=SEGMENT_DIM),
- pd.Index(x_points.coords[BREAKPOINT_DIM].values, name=BREAKPOINT_DIM),
+ pd.Index(stacked_bp.coords[SEGMENT_DIM].values, name=SEGMENT_DIM),
+ pd.Index(stacked_bp.coords[dim].values, name=dim),
]
binary_coords = extra + [
- pd.Index(x_points.coords[SEGMENT_DIM].values, name=SEGMENT_DIM),
+ pd.Index(stacked_bp.coords[SEGMENT_DIM].values, name=SEGMENT_DIM),
]
- binary_mask = (
- lambda_mask.any(dim=BREAKPOINT_DIM) if lambda_mask is not None else None
- )
+ # Masks
+ lambda_mask = None
+ binary_mask = None
+ if stacked_mask is not None:
+ # Aggregate across link_dim — all variables must be valid
+ agg_mask = stacked_mask.all(dim=link_dim)
+ lambda_mask = agg_mask
+ binary_mask = agg_mask.any(dim=dim)
+
+ binary_name = f"{name}{PWL_BINARY_SUFFIX}"
+ select_name = f"{name}{PWL_SELECT_SUFFIX}"
+ lambda_name = f"{name}{PWL_LAMBDA_SUFFIX}"
+ convex_name = f"{name}{PWL_CONVEX_SUFFIX}"
+ link_name = f"{name}{PWL_X_LINK_SUFFIX}"
binary_var = model.add_variables(
binary=True, coords=binary_coords, name=binary_name, mask=binary_mask
)
- # Segment selection: sum(z_k) == 1 or sum(z_k) == active
rhs = active if active is not None else 1
- select_con = model.add_constraints(
- binary_var.sum(dim=SEGMENT_DIM) == rhs, name=select_name
- )
+ model.add_constraints(binary_var.sum(dim=SEGMENT_DIM) == rhs, name=select_name)
lambda_var = model.add_variables(
lower=0, upper=1, coords=lambda_coords, name=lambda_name, mask=lambda_mask
)
- model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=BREAKPOINT_DIM)
-
- model.add_constraints(
- lambda_var.sum(dim=BREAKPOINT_DIM) == binary_var, name=convex_name
- )
-
- x_weighted = (lambda_var * x_points).sum(dim=[SEGMENT_DIM, BREAKPOINT_DIM])
- model.add_constraints(x_expr == x_weighted, name=x_link_name)
-
- y_weighted = (lambda_var * y_points).sum(dim=[SEGMENT_DIM, BREAKPOINT_DIM])
- model.add_constraints(target_expr == y_weighted, name=y_link_name)
-
- return select_con
+ model.add_sos_constraints(lambda_var, sos_type=2, sos_dim=dim)
+ model.add_constraints(lambda_var.sum(dim=dim) == binary_var, name=convex_name)
-# ---------------------------------------------------------------------------
-# Main entry point
-# ---------------------------------------------------------------------------
-
-
-def add_piecewise_constraints(
- model: Model,
- descriptor: PiecewiseConstraintDescriptor | Constraint,
- method: Literal["sos2", "incremental", "auto", "lp"] = "auto",
- name: str | None = None,
- skip_nan_check: bool = False,
-) -> Constraint:
- """
- Add a piecewise linear constraint from a :class:`PiecewiseConstraintDescriptor`.
-
- Typically called as::
-
- m.add_piecewise_constraints(piecewise(x, x_points, y_points) >= y)
-
- Parameters
- ----------
- model : Model
- The linopy model.
- descriptor : PiecewiseConstraintDescriptor
- Created by comparing a variable/expression with a :class:`PiecewiseExpression`.
- method : {"auto", "sos2", "incremental", "lp"}, default "auto"
- Formulation method.
- name : str, optional
- Base name for generated variables/constraints.
- skip_nan_check : bool, default False
- If True, skip NaN detection.
-
- Returns
- -------
- Constraint
- """
- if not isinstance(descriptor, PiecewiseConstraintDescriptor):
- raise TypeError(
- f"Expected PiecewiseConstraintDescriptor, got {type(descriptor)}. "
- f"Use: m.add_piecewise_constraints(piecewise(x, x_points, y_points) >= y)"
- )
-
- if method not in ("sos2", "incremental", "auto", "lp"):
- raise ValueError(
- f"method must be 'sos2', 'incremental', 'auto', or 'lp', got '{method}'"
- )
-
- pw = descriptor.piecewise_func
- sign = descriptor.sign
- y_lhs = descriptor.lhs
- x_expr_raw = pw.expr
- x_points = pw.x_points
- y_points = pw.y_points
- disjunctive = pw.disjunctive
- active = pw.active
-
- # Broadcast points to match expression dimensions
- x_points = _broadcast_points(x_points, x_expr_raw, y_lhs, disjunctive=disjunctive)
- y_points = _broadcast_points(y_points, x_expr_raw, y_lhs, disjunctive=disjunctive)
-
- # Compute mask
- mask = _compute_combined_mask(x_points, y_points, skip_nan_check)
-
- # Name
- if name is None:
- name = f"pwl{model._pwlCounter}"
- model._pwlCounter += 1
-
- # Convert to LinearExpressions
- x_expr = _to_linexpr(x_expr_raw)
- y_expr = _to_linexpr(y_lhs)
-
- # Convert active to LinearExpression if provided
- active_expr = _to_linexpr(active) if active is not None else None
-
- # Validate: active is not supported with LP method
- if active_expr is not None and method == "lp":
- raise ValueError(
- "The 'active' parameter is not supported with method='lp'. "
- "Use method='incremental' or method='sos2'."
- )
-
- if disjunctive:
- return _add_disjunctive(
- model,
- name,
- x_expr,
- y_expr,
- sign,
- x_points,
- y_points,
- mask,
- method,
- active_expr,
- )
- else:
- return _add_continuous(
- model,
- name,
- x_expr,
- y_expr,
- sign,
- x_points,
- y_points,
- mask,
- method,
- skip_nan_check,
- active_expr,
- )
-
-
-def _add_continuous(
- model: Model,
- name: str,
- x_expr: LinearExpression,
- y_expr: LinearExpression,
- sign: str,
- x_points: DataArray,
- y_points: DataArray,
- mask: DataArray | None,
- method: str,
- skip_nan_check: bool,
- active: LinearExpression | None = None,
-) -> Constraint:
- """Handle continuous (non-disjunctive) piecewise constraints."""
- convexity: Literal["convex", "concave", "linear", "mixed"] | None = None
-
- # Determine actual method
- if method == "auto":
- if sign == "==":
- if _check_strict_monotonicity(x_points) and _has_trailing_nan_only(
- x_points
- ):
- method = "incremental"
- else:
- method = "sos2"
- else:
- if not _check_strict_increasing(x_points):
- raise ValueError(
- "Automatic method selection for piecewise inequalities requires "
- "strictly increasing x_points. Pass breakpoints in increasing "
- "x-order or use method='sos2'."
- )
- convexity = _detect_convexity(x_points, y_points)
- if convexity == "linear":
- method = "lp"
- elif (sign == "<=" and convexity == "concave") or (
- sign == ">=" and convexity == "convex"
- ):
- method = "lp"
- else:
- method = "sos2"
- elif method == "lp":
- if sign == "==":
- raise ValueError("Pure LP method is not supported for equality constraints")
- convexity = _detect_convexity(x_points, y_points)
- if convexity != "linear":
- if sign == "<=" and convexity != "concave":
- raise ValueError(
- f"Pure LP method for '<=' requires concave or linear function, "
- f"got {convexity}"
- )
- if sign == ">=" and convexity != "convex":
- raise ValueError(
- f"Pure LP method for '>=' requires convex or linear function, "
- f"got {convexity}"
- )
- elif method == "incremental":
- if not _check_strict_monotonicity(x_points):
- raise ValueError("Incremental method requires strictly monotonic x_points")
- if not _has_trailing_nan_only(x_points):
- raise ValueError(
- "Incremental method does not support non-trailing NaN breakpoints. "
- "NaN values must only appear at the end of the breakpoint sequence."
- )
-
- if method == "sos2":
- _validate_numeric_breakpoint_coords(x_points)
- if not _has_trailing_nan_only(x_points):
- raise ValueError(
- "SOS2 method does not support non-trailing NaN breakpoints. "
- "NaN values must only appear at the end of the breakpoint sequence."
- )
-
- # LP formulation
- if method == "lp":
- if active is not None:
- raise ValueError(
- "The 'active' parameter is not supported with method='lp'. "
- "Use method='incremental' or method='sos2'."
- )
- return _add_pwl_lp(model, name, x_expr, y_expr, sign, x_points, y_points)
-
- # SOS2 or incremental formulation
- if sign == "==":
- # Direct linking: y = f(x)
- if method == "sos2":
- return _add_pwl_sos2_core(
- model, name, x_expr, y_expr, x_points, y_points, mask, active
- )
- else: # incremental
- return _add_pwl_incremental_core(
- model, name, x_expr, y_expr, x_points, y_points, mask, active
- )
- else:
- # Inequality: create aux variable z, enforce z = f(x), then y <= z or y >= z
- aux_name = f"{name}{PWL_AUX_SUFFIX}"
- aux_coords = _extra_coords(x_points, BREAKPOINT_DIM)
- z = model.add_variables(coords=aux_coords, name=aux_name)
- z_expr = _to_linexpr(z)
-
- if method == "sos2":
- result = _add_pwl_sos2_core(
- model, name, x_expr, z_expr, x_points, y_points, mask, active
- )
- else: # incremental
- result = _add_pwl_incremental_core(
- model, name, x_expr, z_expr, x_points, y_points, mask, active
- )
-
- # Add inequality
- ineq_name = f"{name}_ineq"
- if sign == "<=":
- model.add_constraints(y_expr <= z_expr, name=ineq_name)
- else:
- model.add_constraints(y_expr >= z_expr, name=ineq_name)
-
- return result
-
-
-def _add_disjunctive(
- model: Model,
- name: str,
- x_expr: LinearExpression,
- y_expr: LinearExpression,
- sign: str,
- x_points: DataArray,
- y_points: DataArray,
- mask: DataArray | None,
- method: str,
- active: LinearExpression | None = None,
-) -> Constraint:
- """Handle disjunctive piecewise constraints."""
- if method == "lp":
- raise ValueError("Pure LP method is not supported for disjunctive constraints")
- if method == "incremental":
- raise ValueError(
- "Incremental method is not supported for disjunctive constraints"
- )
-
- _validate_numeric_breakpoint_coords(x_points)
- if not _has_trailing_nan_only(x_points):
- raise ValueError(
- "Disjunctive SOS2 does not support non-trailing NaN breakpoints. "
- "NaN values must only appear at the end of the breakpoint sequence."
- )
-
- if sign == "==":
- return _add_dpwl_sos2_core(
- model, name, x_expr, y_expr, x_points, y_points, mask, active
- )
- else:
- # Create aux variable z, disjunctive SOS2 for z = f(x), then y <= z or y >= z
- aux_name = f"{name}{PWL_AUX_SUFFIX}"
- aux_coords = _extra_coords(x_points, BREAKPOINT_DIM, SEGMENT_DIM)
- z = model.add_variables(coords=aux_coords, name=aux_name)
- z_expr = _to_linexpr(z)
-
- result = _add_dpwl_sos2_core(
- model, name, x_expr, z_expr, x_points, y_points, mask, active
- )
-
- ineq_name = f"{name}_ineq"
- if sign == "<=":
- model.add_constraints(y_expr <= z_expr, name=ineq_name)
- else:
- model.add_constraints(y_expr >= z_expr, name=ineq_name)
-
- return result
+ weighted = (lambda_var * stacked_bp).sum(dim=[SEGMENT_DIM, dim])
+ return model.add_constraints(target_expr == weighted, name=link_name)
diff --git a/linopy/types.py b/linopy/types.py
index 7238c552..0e3662bf 100644
--- a/linopy/types.py
+++ b/linopy/types.py
@@ -17,7 +17,6 @@
QuadraticExpression,
ScalarLinearExpression,
)
- from linopy.piecewise import PiecewiseConstraintDescriptor
from linopy.variables import ScalarVariable, Variable
# Type aliases using Union for Python 3.9 compatibility
@@ -47,9 +46,7 @@
"LinearExpression",
"QuadraticExpression",
]
-ConstraintLike = Union[
- "Constraint", "AnonymousScalarConstraint", "PiecewiseConstraintDescriptor"
-]
+ConstraintLike = Union["Constraint", "AnonymousScalarConstraint"]
LinExprLike = Union["Variable", "LinearExpression"]
MaskLike = Union[numpy.ndarray, DataArray, Series, DataFrame] # noqa: UP007
SideLike = Union[ConstantLike, VariableLike, ExpressionLike] # noqa: UP007
diff --git a/linopy/variables.py b/linopy/variables.py
index 51f57a6d..0dfca099 100644
--- a/linopy/variables.py
+++ b/linopy/variables.py
@@ -79,7 +79,6 @@
ScalarLinearExpression,
)
from linopy.model import Model
- from linopy.piecewise import PiecewiseConstraintDescriptor, PiecewiseExpression
logger = logging.getLogger(__name__)
@@ -537,31 +536,13 @@ def __rsub__(self, other: ConstantLike) -> LinearExpression:
except TypeError:
return NotImplemented
- @overload
- def __le__(self, other: PiecewiseExpression) -> PiecewiseConstraintDescriptor: ...
-
- @overload
- def __le__(self, other: SideLike) -> Constraint: ...
-
- def __le__(self, other: SideLike) -> Constraint | PiecewiseConstraintDescriptor:
+ def __le__(self, other: SideLike) -> Constraint:
return self.to_linexpr().__le__(other)
- @overload
- def __ge__(self, other: PiecewiseExpression) -> PiecewiseConstraintDescriptor: ...
-
- @overload
- def __ge__(self, other: SideLike) -> Constraint: ...
-
- def __ge__(self, other: SideLike) -> Constraint | PiecewiseConstraintDescriptor:
+ def __ge__(self, other: SideLike) -> Constraint:
return self.to_linexpr().__ge__(other)
- @overload # type: ignore[override]
- def __eq__(self, other: PiecewiseExpression) -> PiecewiseConstraintDescriptor: ...
-
- @overload
- def __eq__(self, other: SideLike) -> Constraint: ...
-
- def __eq__(self, other: SideLike) -> Constraint | PiecewiseConstraintDescriptor:
+ def __eq__(self, other: SideLike) -> Constraint: # type: ignore
return self.to_linexpr().__eq__(other)
def __gt__(self, other: Any) -> NotImplementedType:
diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py
index ab8e1f09..dbc038fd 100644
--- a/test/test_piecewise_constraints.py
+++ b/test/test_piecewise_constraints.py
@@ -13,15 +13,14 @@
Model,
available_solvers,
breakpoints,
- piecewise,
segments,
slopes_to_points,
+ tangent_lines,
)
from linopy.constants import (
BREAKPOINT_DIM,
LP_SEG_DIM,
PWL_ACTIVE_BOUND_SUFFIX,
- PWL_AUX_SUFFIX,
PWL_BINARY_SUFFIX,
PWL_CONVEX_SUFFIX,
PWL_DELTA_SUFFIX,
@@ -30,17 +29,10 @@
PWL_INC_LINK_SUFFIX,
PWL_INC_ORDER_SUFFIX,
PWL_LAMBDA_SUFFIX,
- PWL_LP_DOMAIN_SUFFIX,
- PWL_LP_SUFFIX,
PWL_SELECT_SUFFIX,
PWL_X_LINK_SUFFIX,
- PWL_Y_LINK_SUFFIX,
SEGMENT_DIM,
)
-from linopy.piecewise import (
- PiecewiseConstraintDescriptor,
- PiecewiseExpression,
-)
from linopy.solver_capabilities import SolverFeature, get_available_solvers_with_feature
_sos2_solvers = get_available_solvers_with_feature(
@@ -281,168 +273,7 @@ def test_dataarray_missing_dim_raises(self) -> None:
# ===========================================================================
-# piecewise() and operator overloading
-# ===========================================================================
-
-
-class TestPiecewiseFunction:
- def test_returns_expression(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- pw = piecewise(x, x_points=[0, 10, 50], y_points=[5, 2, 20])
- assert isinstance(pw, PiecewiseExpression)
-
- def test_series_inputs(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- pw = piecewise(x, pd.Series([0, 10, 50]), pd.Series([5, 2, 20]))
- assert isinstance(pw, PiecewiseExpression)
-
- def test_tuple_inputs(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- pw = piecewise(x, (0, 10, 50), (5, 2, 20))
- assert isinstance(pw, PiecewiseExpression)
-
- def test_eq_returns_descriptor(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- desc = piecewise(x, [0, 10, 50], [5, 2, 20]) == y
- assert isinstance(desc, PiecewiseConstraintDescriptor)
- assert desc.sign == "=="
-
- def test_ge_returns_le_descriptor(self) -> None:
- """Pw >= y means y <= pw"""
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- desc = piecewise(x, [0, 10, 50], [5, 2, 20]) >= y
- assert isinstance(desc, PiecewiseConstraintDescriptor)
- assert desc.sign == "<="
-
- def test_le_returns_ge_descriptor(self) -> None:
- """Pw <= y means y >= pw"""
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- desc = piecewise(x, [0, 10, 50], [5, 2, 20]) <= y
- assert isinstance(desc, PiecewiseConstraintDescriptor)
- assert desc.sign == ">="
-
- @pytest.mark.parametrize(
- ("operator", "expected_sign"),
- [("==", "=="), ("<=", "<="), (">=", ">=")],
- )
- def test_rhs_piecewise_returns_descriptor(
- self, operator: str, expected_sign: str
- ) -> None:
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- pw = piecewise(x, [0, 10, 50], [5, 2, 20])
-
- if operator == "==":
- desc = y == pw
- elif operator == "<=":
- desc = y <= pw
- else:
- desc = y >= pw
-
- assert isinstance(desc, PiecewiseConstraintDescriptor)
- assert desc.sign == expected_sign
- assert desc.piecewise_func is pw
-
- @pytest.mark.parametrize(
- ("operator", "expected_sign"),
- [("==", "=="), ("<=", "<="), (">=", ">=")],
- )
- def test_rhs_piecewise_linear_expression_returns_descriptor(
- self, operator: str, expected_sign: str
- ) -> None:
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- z = m.add_variables(name="z")
- lhs = 2 * y + z
- pw = piecewise(x, [0, 10, 50], [5, 2, 20])
-
- if operator == "==":
- desc = lhs == pw
- elif operator == "<=":
- desc = lhs <= pw
- else:
- desc = lhs >= pw
-
- assert isinstance(desc, PiecewiseConstraintDescriptor)
- assert desc.sign == expected_sign
- assert desc.lhs is lhs
- assert desc.piecewise_func is pw
-
- def test_rhs_piecewise_add_constraint(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- m.add_piecewise_constraints(y == piecewise(x, [0, 10, 50], [5, 2, 20]))
- assert len(m.constraints) > 0
-
- def test_mismatched_sizes_raises(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- with pytest.raises(ValueError, match="same size"):
- piecewise(x, [0, 10, 50, 100], [5, 2, 20])
-
- def test_missing_breakpoint_dim_raises(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- xp = xr.DataArray([0, 10, 50], dims=["knot"])
- yp = xr.DataArray([5, 2, 20], dims=["knot"])
- with pytest.raises(ValueError, match="must have a breakpoint dimension"):
- piecewise(x, xp, yp)
-
- def test_missing_breakpoint_dim_x_only_raises(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- xp = xr.DataArray([0, 10, 50], dims=["knot"])
- yp = xr.DataArray([5, 2, 20], dims=[BREAKPOINT_DIM])
- with pytest.raises(
- ValueError, match="x_points is missing the breakpoint dimension"
- ):
- piecewise(x, xp, yp)
-
- def test_missing_breakpoint_dim_y_only_raises(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- xp = xr.DataArray([0, 10, 50], dims=[BREAKPOINT_DIM])
- yp = xr.DataArray([5, 2, 20], dims=["knot"])
- with pytest.raises(
- ValueError, match="y_points is missing the breakpoint dimension"
- ):
- piecewise(x, xp, yp)
-
- def test_segment_dim_mismatch_raises(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- xp = segments([[0, 10], [50, 100]])
- yp = xr.DataArray([0, 5], dims=[BREAKPOINT_DIM])
- with pytest.raises(ValueError, match="segment.*dimension.*both must"):
- piecewise(x, xp, yp)
-
- def test_detects_disjunctive(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- pw = piecewise(x, segments([[0, 10], [50, 100]]), segments([[0, 5], [20, 80]]))
- assert pw.disjunctive is True
-
- def test_detects_continuous(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- pw = piecewise(x, [0, 10, 50], [5, 2, 20])
- assert pw.disjunctive is False
-
-
-# ===========================================================================
-# Continuous piecewise – equality
+# Continuous piecewise -- equality
# ===========================================================================
@@ -452,13 +283,14 @@ def test_sos2(self) -> None:
x = m.add_variables(name="x")
y = m.add_variables(name="y")
m.add_piecewise_constraints(
- piecewise(x, [0, 10, 50, 100], [5, 2, 20, 80]) == y,
+ (x, [0, 10, 50, 100]),
+ (y, [5, 2, 20, 80]),
method="sos2",
)
assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables
assert f"pwl0{PWL_CONVEX_SUFFIX}" in m.constraints
assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints
- assert f"pwl0{PWL_Y_LINK_SUFFIX}" in m.constraints
+ # N-var path uses a single stacked link constraint (no separate y_link)
lam = m.variables[f"pwl0{PWL_LAMBDA_SUFFIX}"]
assert lam.attrs.get("sos_type") == 2
@@ -466,8 +298,10 @@ def test_auto_selects_incremental_for_monotonic(self) -> None:
m = Model()
x = m.add_variables(name="x")
y = m.add_variables(name="y")
+ # Both breakpoint sequences must be monotonic for incremental
m.add_piecewise_constraints(
- piecewise(x, [0, 10, 50, 100], [5, 2, 20, 80]) == y,
+ (x, [0, 10, 50, 100]),
+ (y, [0, 5, 20, 80]),
)
assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables
assert f"pwl0{PWL_LAMBDA_SUFFIX}" not in m.variables
@@ -476,8 +310,10 @@ def test_auto_nonmonotonic_falls_back_to_sos2(self) -> None:
m = Model()
x = m.add_variables(name="x")
y = m.add_variables(name="y")
+ # Non-monotonic y-breakpoints force SOS2
m.add_piecewise_constraints(
- piecewise(x, [0, 50, 30, 100], [5, 20, 15, 80]) == y,
+ (x, [0, 50, 30, 100]),
+ (y, [5, 20, 15, 80]),
)
assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables
assert f"pwl0{PWL_DELTA_SUFFIX}" not in m.variables
@@ -488,16 +324,18 @@ def test_multi_dimensional(self) -> None:
x = m.add_variables(coords=[gens], name="x")
y = m.add_variables(coords=[gens], name="y")
m.add_piecewise_constraints(
- piecewise(
+ (
x,
breakpoints(
{"gen_a": [0, 10, 50], "gen_b": [0, 20, 80]}, dim="generator"
),
+ ),
+ (
+ y,
breakpoints(
{"gen_a": [0, 5, 30], "gen_b": [0, 8, 50]}, dim="generator"
),
- )
- == y,
+ ),
)
delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"]
assert "generator" in delta.dims
@@ -506,125 +344,72 @@ def test_with_slopes(self) -> None:
m = Model()
x = m.add_variables(name="x")
y = m.add_variables(name="y")
+ # slopes=[-0.3, 0.45, 1.2] with y0=5 -> y_points=[5, 2, 20, 80]
+ # Non-monotonic y-breakpoints, so auto selects SOS2
m.add_piecewise_constraints(
- piecewise(
- x,
- [0, 10, 50, 100],
- breakpoints(slopes=[-0.3, 0.45, 1.2], x_points=[0, 10, 50, 100], y0=5),
- )
- == y,
+ (x, [0, 10, 50, 100]),
+ (y, breakpoints(slopes=[-0.3, 0.45, 1.2], x_points=[0, 10, 50, 100], y0=5)),
)
- assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables
+ assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables
# ===========================================================================
-# Continuous piecewise – inequality
+# Piecewise Envelope
# ===========================================================================
-class TestContinuousInequality:
- def test_concave_le_uses_lp(self) -> None:
- """Y <= concave f(x) → LP tangent lines"""
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- # Concave: slopes 0.8, 0.4 (decreasing)
- # pw >= y means y <= pw (sign="<=")
- m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 40, 60]) >= y,
- )
- assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints
- assert f"pwl0{PWL_LAMBDA_SUFFIX}" not in m.variables
- assert f"pwl0{PWL_AUX_SUFFIX}" not in m.variables
-
- def test_convex_le_uses_sos2_aux(self) -> None:
- """Y <= convex f(x) → SOS2 + aux"""
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- # Convex: slopes 0.2, 1.0 (increasing)
- m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 10, 60]) >= y,
- )
- assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables
- assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables
-
- def test_convex_ge_uses_lp(self) -> None:
- """Y >= convex f(x) → LP tangent lines"""
+class TestTangentLines:
+ def test_basic_variable(self) -> None:
+ """Envelope from a Variable produces a LinearExpression with seg dim."""
m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- # Convex: slopes 0.2, 1.0 (increasing)
- # pw <= y means y >= pw (sign=">=")
- m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 10, 60]) <= y,
- )
- assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints
- assert f"pwl0{PWL_LAMBDA_SUFFIX}" not in m.variables
- assert f"pwl0{PWL_AUX_SUFFIX}" not in m.variables
+ x = m.add_variables(name="x", lower=0, upper=100)
+ env = tangent_lines(x, [0, 50, 100], [0, 40, 60])
+ assert LP_SEG_DIM in env.dims
- def test_concave_ge_uses_sos2_aux(self) -> None:
- """Y >= concave f(x) → SOS2 + aux"""
+ def test_basic_linexpr(self) -> None:
+ """Envelope from a LinearExpression works too."""
m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- # Concave: slopes 0.8, 0.4 (decreasing)
- m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 40, 60]) <= y,
- )
- assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables
- assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables
+ x = m.add_variables(name="x", lower=0, upper=100)
+ env = tangent_lines(1 * x, [0, 50, 100], [0, 40, 60])
+ assert LP_SEG_DIM in env.dims
- def test_mixed_uses_sos2(self) -> None:
+ def test_segment_count(self) -> None:
+ """Number of segments = number of breakpoints - 1."""
m = Model()
x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- # Mixed: slopes 0.5, 0.3, 0.9 (down then up)
- m.add_piecewise_constraints(
- piecewise(x, [0, 30, 60, 100], [0, 15, 24, 60]) >= y,
- )
- assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables
- assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables
+ env = tangent_lines(x, [0, 50, 100], [0, 40, 60])
+ assert env.sizes[LP_SEG_DIM] == 2
- def test_method_lp_wrong_convexity_raises(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- # Convex function + y <= pw + method="lp" should fail
- with pytest.raises(ValueError, match="convex"):
- m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 10, 60]) >= y,
- method="lp",
- )
+ def test_invalid_x_type_raises(self) -> None:
+ with pytest.raises(TypeError, match="must be a Variable or LinearExpression"):
+ tangent_lines(42, [0, 50, 100], [0, 40, 60]) # type: ignore
- def test_method_lp_decreasing_breakpoints_raises(self) -> None:
+ def test_concave_le_constraint(self) -> None:
+ """Using envelope with <= constraint creates regular constraints."""
m = Model()
- x = m.add_variables(name="x")
+ x = m.add_variables(name="x", lower=0, upper=100)
y = m.add_variables(name="y")
- with pytest.raises(ValueError, match="strictly increasing x_points"):
- m.add_piecewise_constraints(
- piecewise(x, [100, 50, 0], [60, 10, 0]) <= y,
- method="lp",
- )
+ env = tangent_lines(x, [0, 50, 100], [0, 40, 60])
+ m.add_constraints(y <= env, name="pwl")
+ assert "pwl" in m.constraints
- def test_auto_inequality_decreasing_breakpoints_raises(self) -> None:
+ def test_convex_ge_constraint(self) -> None:
+ """Using envelope with >= constraint creates regular constraints."""
m = Model()
- x = m.add_variables(name="x")
+ x = m.add_variables(name="x", lower=0, upper=100)
y = m.add_variables(name="y")
- with pytest.raises(ValueError, match="strictly increasing x_points"):
- m.add_piecewise_constraints(
- piecewise(x, [100, 50, 0], [60, 10, 0]) <= y,
- )
+ env = tangent_lines(x, [0, 50, 100], [0, 10, 60])
+ m.add_constraints(y >= env, name="pwl")
+ assert "pwl" in m.constraints
- def test_method_lp_equality_raises(self) -> None:
+ def test_dataarray_breakpoints(self) -> None:
+ """Envelope accepts DataArray breakpoints."""
m = Model()
x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- with pytest.raises(ValueError, match="equality"):
- m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 40, 60]) == y,
- method="lp",
- )
+ x_pts = xr.DataArray([0, 50, 100], dims=[BREAKPOINT_DIM])
+ y_pts = xr.DataArray([0, 40, 60], dims=[BREAKPOINT_DIM])
+ env = tangent_lines(x, x_pts, y_pts)
+ assert LP_SEG_DIM in env.dims
# ===========================================================================
@@ -638,7 +423,8 @@ def test_creates_delta_vars(self) -> None:
x = m.add_variables(name="x")
y = m.add_variables(name="y")
m.add_piecewise_constraints(
- piecewise(x, [0, 10, 50, 100], [5, 2, 20, 80]) == y,
+ (x, [0, 10, 50, 100]),
+ (y, [5, 10, 20, 80]),
method="incremental",
)
assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables
@@ -653,7 +439,8 @@ def test_nonmonotonic_raises(self) -> None:
y = m.add_variables(name="y")
with pytest.raises(ValueError, match="strictly monotonic"):
m.add_piecewise_constraints(
- piecewise(x, [0, 50, 30, 100], [5, 20, 15, 80]) == y,
+ (x, [0, 50, 30, 100]),
+ (y, [5, 20, 15, 80]),
method="incremental",
)
@@ -662,7 +449,8 @@ def test_sos2_nonmonotonic_succeeds(self) -> None:
x = m.add_variables(name="x")
y = m.add_variables(name="y")
m.add_piecewise_constraints(
- piecewise(x, [0, 50, 30, 100], [5, 20, 15, 80]) == y,
+ (x, [0, 50, 30, 100]),
+ (y, [5, 20, 15, 80]),
method="sos2",
)
assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables
@@ -673,20 +461,22 @@ def test_two_breakpoints_no_fill(self) -> None:
x = m.add_variables(name="x")
y = m.add_variables(name="y")
m.add_piecewise_constraints(
- piecewise(x, [0, 100], [5, 80]) == y,
+ (x, [0, 100]),
+ (y, [5, 80]),
method="incremental",
)
delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"]
assert delta.labels.sizes[LP_SEG_DIM] == 1
assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints
- assert f"pwl0{PWL_Y_LINK_SUFFIX}" in m.constraints
+ # N-var path uses a single stacked link constraint (no separate y_link)
def test_creates_binary_indicator_vars(self) -> None:
m = Model()
x = m.add_variables(name="x")
y = m.add_variables(name="y")
m.add_piecewise_constraints(
- piecewise(x, [0, 10, 50, 100], [5, 2, 20, 80]) == y,
+ (x, [0, 10, 50, 100]),
+ (y, [5, 10, 20, 80]),
method="incremental",
)
assert f"pwl0{PWL_INC_BINARY_SUFFIX}" in m.variables
@@ -699,7 +489,8 @@ def test_creates_order_constraints(self) -> None:
x = m.add_variables(name="x")
y = m.add_variables(name="y")
m.add_piecewise_constraints(
- piecewise(x, [0, 10, 50, 100], [5, 2, 20, 80]) == y,
+ (x, [0, 10, 50, 100]),
+ (y, [5, 10, 20, 80]),
method="incremental",
)
assert f"pwl0{PWL_INC_ORDER_SUFFIX}" in m.constraints
@@ -710,7 +501,8 @@ def test_two_breakpoints_no_order_constraint(self) -> None:
x = m.add_variables(name="x")
y = m.add_variables(name="y")
m.add_piecewise_constraints(
- piecewise(x, [0, 100], [5, 80]) == y,
+ (x, [0, 100]),
+ (y, [5, 80]),
method="incremental",
)
assert f"pwl0{PWL_INC_BINARY_SUFFIX}" in m.variables
@@ -722,7 +514,8 @@ def test_decreasing_monotonic(self) -> None:
x = m.add_variables(name="x")
y = m.add_variables(name="y")
m.add_piecewise_constraints(
- piecewise(x, [100, 50, 10, 0], [80, 20, 2, 5]) == y,
+ (x, [100, 50, 10, 0]),
+ (y, [80, 20, 5, 2]),
method="incremental",
)
assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables
@@ -739,8 +532,8 @@ def test_equality_creates_binary(self) -> None:
x = m.add_variables(name="x")
y = m.add_variables(name="y")
m.add_piecewise_constraints(
- piecewise(x, segments([[0, 10], [50, 100]]), segments([[0, 5], [20, 80]]))
- == y,
+ (x, segments([[0, 10], [50, 100]])),
+ (y, segments([[0, 5], [20, 80]])),
)
assert f"pwl0{PWL_BINARY_SUFFIX}" in m.variables
assert f"pwl0{PWL_SELECT_SUFFIX}" in m.constraints
@@ -749,41 +542,14 @@ def test_equality_creates_binary(self) -> None:
lam = m.variables[f"pwl0{PWL_LAMBDA_SUFFIX}"]
assert lam.attrs.get("sos_type") == 2
- def test_inequality_creates_aux(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- m.add_piecewise_constraints(
- piecewise(x, segments([[0, 10], [50, 100]]), segments([[0, 5], [20, 80]]))
- >= y,
- )
- assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables
- assert f"pwl0{PWL_BINARY_SUFFIX}" in m.variables
- assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables
-
- def test_method_lp_raises(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- with pytest.raises(ValueError, match="disjunctive"):
- m.add_piecewise_constraints(
- piecewise(
- x, segments([[0, 10], [50, 100]]), segments([[0, 5], [20, 80]])
- )
- >= y,
- method="lp",
- )
-
def test_method_incremental_raises(self) -> None:
m = Model()
x = m.add_variables(name="x")
y = m.add_variables(name="y")
with pytest.raises(ValueError, match="disjunctive"):
m.add_piecewise_constraints(
- piecewise(
- x, segments([[0, 10], [50, 100]]), segments([[0, 5], [20, 80]])
- )
- == y,
+ (x, segments([[0, 10], [50, 100]])),
+ (y, segments([[0, 5], [20, 80]])),
method="incremental",
)
@@ -793,24 +559,43 @@ def test_multi_dimensional(self) -> None:
x = m.add_variables(coords=[gens], name="x")
y = m.add_variables(coords=[gens], name="y")
m.add_piecewise_constraints(
- piecewise(
+ (
x,
segments(
{"gen_a": [[0, 10], [50, 100]], "gen_b": [[0, 20], [60, 90]]},
dim="generator",
),
+ ),
+ (
+ y,
segments(
{"gen_a": [[0, 5], [20, 80]], "gen_b": [[0, 8], [30, 70]]},
dim="generator",
),
- )
- == y,
+ ),
)
binary = m.variables[f"pwl0{PWL_BINARY_SUFFIX}"]
lam = m.variables[f"pwl0{PWL_LAMBDA_SUFFIX}"]
assert "generator" in binary.dims
assert "generator" in lam.dims
+ def test_three_variables(self) -> None:
+ """Disjunctive with 3 variables creates single link constraint."""
+ m = Model()
+ x = m.add_variables(name="x")
+ y = m.add_variables(name="y")
+ z = m.add_variables(name="z")
+ m.add_piecewise_constraints(
+ (x, segments([[0, 10], [50, 100]])),
+ (y, segments([[0, 5], [20, 80]])),
+ (z, segments([[0, 3], [15, 60]])),
+ )
+ assert f"pwl0{PWL_BINARY_SUFFIX}" in m.variables
+ assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables
+ # Single link constraint with _pwl_var dimension
+ link = m.constraints[f"pwl0{PWL_X_LINK_SUFFIX}"]
+ assert "_pwl_var" in [str(d) for d in link.dims]
+
# ===========================================================================
# Validation
@@ -818,11 +603,11 @@ def test_multi_dimensional(self) -> None:
class TestValidation:
- def test_non_descriptor_raises(self) -> None:
+ def test_wrong_arg_types_raises(self) -> None:
m = Model()
x = m.add_variables(name="x")
- with pytest.raises(TypeError, match="PiecewiseConstraintDescriptor"):
- m.add_piecewise_constraints(x) # type: ignore
+ with pytest.raises(TypeError, match="at least 2"):
+ m.add_piecewise_constraints((x, [0, 10, 50]))
def test_invalid_method_raises(self) -> None:
m = Model()
@@ -830,10 +615,27 @@ def test_invalid_method_raises(self) -> None:
y = m.add_variables(name="y")
with pytest.raises(ValueError, match="method must be"):
m.add_piecewise_constraints(
- piecewise(x, [0, 10, 50], [5, 2, 20]) == y,
+ (x, [0, 10, 50]),
+ (y, [5, 10, 20]),
method="invalid", # type: ignore
)
+ def test_mismatched_breakpoint_sizes_raises(self) -> None:
+ m = Model()
+ x = m.add_variables(name="x")
+ y = m.add_variables(name="y")
+ with pytest.raises(ValueError, match="same size"):
+ m.add_piecewise_constraints(
+ (x, [0, 10, 50]),
+ (y, [5, 10]),
+ )
+
+ def test_non_tuple_arg_raises(self) -> None:
+ m = Model()
+ x = m.add_variables(name="x")
+ with pytest.raises(TypeError, match="tuple"):
+ m.add_piecewise_constraints(x, [0, 10, 50]) # type: ignore
+
# ===========================================================================
# Name generation
@@ -846,8 +648,8 @@ def test_auto_name(self) -> None:
x = m.add_variables(name="x")
y = m.add_variables(name="y")
z = m.add_variables(name="z")
- m.add_piecewise_constraints(piecewise(x, [0, 10, 50], [5, 2, 20]) == y)
- m.add_piecewise_constraints(piecewise(x, [0, 20, 80], [10, 15, 50]) == z)
+ m.add_piecewise_constraints((x, [0, 10, 50]), (y, [5, 10, 20]))
+ m.add_piecewise_constraints((x, [0, 20, 80]), (z, [10, 15, 50]))
assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables
assert f"pwl1{PWL_DELTA_SUFFIX}" in m.variables
@@ -856,12 +658,13 @@ def test_custom_name(self) -> None:
x = m.add_variables(name="x")
y = m.add_variables(name="y")
m.add_piecewise_constraints(
- piecewise(x, [0, 10, 50], [5, 2, 20]) == y,
+ (x, [0, 10, 50]),
+ (y, [5, 10, 20]),
name="my_pwl",
)
assert f"my_pwl{PWL_DELTA_SUFFIX}" in m.variables
assert f"my_pwl{PWL_X_LINK_SUFFIX}" in m.constraints
- assert f"my_pwl{PWL_Y_LINK_SUFFIX}" in m.constraints
+ # N-var path uses a single stacked link constraint (no separate y_link)
# ===========================================================================
@@ -876,18 +679,20 @@ def test_broadcast_over_extra_dims(self) -> None:
times = pd.Index([0, 1, 2], name="time")
x = m.add_variables(coords=[gens, times], name="x")
y = m.add_variables(coords=[gens, times], name="y")
- # Points only have generator dim → broadcast over time
+ # Points only have generator dim -> broadcast over time
m.add_piecewise_constraints(
- piecewise(
+ (
x,
breakpoints(
{"gen_a": [0, 10, 50], "gen_b": [0, 20, 80]}, dim="generator"
),
+ ),
+ (
+ y,
breakpoints(
{"gen_a": [0, 5, 30], "gen_b": [0, 8, 50]}, dim="generator"
),
- )
- == y,
+ ),
)
delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"]
assert "generator" in delta.dims
@@ -908,7 +713,8 @@ def test_nan_masks_lambda_labels(self) -> None:
x_pts = xr.DataArray([0, 10, 50, np.nan], dims=[BREAKPOINT_DIM])
y_pts = xr.DataArray([0, 5, 20, np.nan], dims=[BREAKPOINT_DIM])
m.add_piecewise_constraints(
- piecewise(x, x_pts, y_pts) == y,
+ (x, x_pts),
+ (y, y_pts),
method="sos2",
)
lam = m.variables[f"pwl0{PWL_LAMBDA_SUFFIX}"]
@@ -916,35 +722,6 @@ def test_nan_masks_lambda_labels(self) -> None:
assert (lam.labels.isel({BREAKPOINT_DIM: slice(None, 3)}) != -1).all()
assert int(lam.labels.isel({BREAKPOINT_DIM: 3})) == -1
- def test_skip_nan_check_with_nan_raises(self) -> None:
- """skip_nan_check=True with NaN breakpoints raises ValueError."""
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- x_pts = xr.DataArray([0, 10, 50, np.nan], dims=[BREAKPOINT_DIM])
- y_pts = xr.DataArray([0, 5, 20, np.nan], dims=[BREAKPOINT_DIM])
- with pytest.raises(ValueError, match="skip_nan_check=True but breakpoints"):
- m.add_piecewise_constraints(
- piecewise(x, x_pts, y_pts) == y,
- method="sos2",
- skip_nan_check=True,
- )
-
- def test_skip_nan_check_without_nan(self) -> None:
- """skip_nan_check=True without NaN works fine (no mask computed)."""
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- x_pts = xr.DataArray([0, 10, 50, 100], dims=[BREAKPOINT_DIM])
- y_pts = xr.DataArray([0, 5, 20, 40], dims=[BREAKPOINT_DIM])
- m.add_piecewise_constraints(
- piecewise(x, x_pts, y_pts) == y,
- method="sos2",
- skip_nan_check=True,
- )
- lam = m.variables[f"pwl0{PWL_LAMBDA_SUFFIX}"]
- assert (lam.labels != -1).all()
-
def test_sos2_interior_nan_raises(self) -> None:
"""SOS2 with interior NaN breakpoints raises ValueError."""
m = Model()
@@ -954,58 +731,12 @@ def test_sos2_interior_nan_raises(self) -> None:
y_pts = xr.DataArray([0, np.nan, 20, 40], dims=[BREAKPOINT_DIM])
with pytest.raises(ValueError, match="non-trailing NaN"):
m.add_piecewise_constraints(
- piecewise(x, x_pts, y_pts) == y,
+ (x, x_pts),
+ (y, y_pts),
method="sos2",
)
-# ===========================================================================
-# Convexity detection edge cases
-# ===========================================================================
-
-
-class TestConvexityDetection:
- def test_linear_uses_lp_both_directions(self) -> None:
- """Linear function uses LP for both <= and >= inequalities."""
- m = Model()
- x = m.add_variables(lower=0, upper=100, name="x")
- y1 = m.add_variables(name="y1")
- y2 = m.add_variables(name="y2")
- # y1 >= f(x) → LP
- m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 25, 50]) <= y1,
- )
- assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints
- # y2 <= f(x) → also LP (linear is both convex and concave)
- m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 25, 50]) >= y2,
- )
- assert f"pwl1{PWL_LP_SUFFIX}" in m.constraints
-
- def test_single_segment_uses_lp(self) -> None:
- """A single segment (2 breakpoints) is linear; uses LP."""
- m = Model()
- x = m.add_variables(lower=0, upper=100, name="x")
- y = m.add_variables(name="y")
- m.add_piecewise_constraints(
- piecewise(x, [0, 100], [0, 50]) <= y,
- )
- assert f"pwl0{PWL_LP_SUFFIX}" in m.constraints
-
- def test_mixed_convexity_uses_sos2(self) -> None:
- """Mixed convexity should fall back to SOS2 for inequalities."""
- m = Model()
- x = m.add_variables(lower=0, upper=100, name="x")
- y = m.add_variables(name="y")
- # Mixed: slope goes up then down → neither convex nor concave
- # y <= f(x) → piecewise >= y → sign="<=" internally
- m.add_piecewise_constraints(
- piecewise(x, [0, 30, 60, 100], [0, 40, 30, 50]) >= y,
- )
- assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables
- assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables
-
-
# ===========================================================================
# LP file output
# ===========================================================================
@@ -1017,7 +748,8 @@ def test_sos2_equality(self, tmp_path: Path) -> None:
x = m.add_variables(name="x", lower=0, upper=100)
y = m.add_variables(name="y")
m.add_piecewise_constraints(
- piecewise(x, [0.0, 10.0, 50.0, 100.0], [5.0, 2.0, 20.0, 80.0]) == y,
+ (x, [0.0, 10.0, 50.0, 100.0]),
+ (y, [5.0, 2.0, 20.0, 80.0]),
method="sos2",
)
m.add_objective(y)
@@ -1027,31 +759,13 @@ def test_sos2_equality(self, tmp_path: Path) -> None:
assert "sos" in content
assert "s2" in content
- def test_lp_formulation_no_sos2(self, tmp_path: Path) -> None:
- m = Model()
- x = m.add_variables(name="x", lower=0, upper=100)
- y = m.add_variables(name="y")
- # Concave: pw >= y uses LP
- m.add_piecewise_constraints(
- piecewise(x, [0.0, 50.0, 100.0], [0.0, 40.0, 60.0]) >= y,
- )
- m.add_objective(y)
- fn = tmp_path / "pwl_lp.lp"
- m.to_file(fn, io_api="lp")
- content = fn.read_text().lower()
- assert "s2" not in content
-
def test_disjunctive_sos2_and_binary(self, tmp_path: Path) -> None:
m = Model()
x = m.add_variables(name="x", lower=0, upper=100)
y = m.add_variables(name="y")
m.add_piecewise_constraints(
- piecewise(
- x,
- segments([[0.0, 10.0], [50.0, 100.0]]),
- segments([[0.0, 5.0], [20.0, 80.0]]),
- )
- == y,
+ (x, segments([[0.0, 10.0], [50.0, 100.0]])),
+ (y, segments([[0.0, 5.0], [20.0, 80.0]])),
)
m.add_objective(y)
fn = tmp_path / "pwl_disj.lp"
@@ -1062,7 +776,7 @@ def test_disjunctive_sos2_and_binary(self, tmp_path: Path) -> None:
# ===========================================================================
-# Solver integration – SOS2 capable
+# Solver integration -- SOS2 capable
# ===========================================================================
@@ -1077,7 +791,8 @@ def test_equality_minimize_cost(self, solver_name: str) -> None:
x = m.add_variables(lower=0, upper=100, name="x")
cost = m.add_variables(name="cost")
m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 10, 50]) == cost,
+ (x, [0, 50, 100]),
+ (cost, [0, 10, 50]),
)
m.add_constraints(x >= 50, name="x_min")
m.add_objective(cost)
@@ -1091,7 +806,8 @@ def test_equality_maximize_efficiency(self, solver_name: str) -> None:
power = m.add_variables(lower=0, upper=100, name="power")
eff = m.add_variables(name="eff")
m.add_piecewise_constraints(
- piecewise(power, [0, 25, 50, 75, 100], [0.7, 0.85, 0.95, 0.9, 0.8]) == eff,
+ (power, [0, 25, 50, 75, 100]),
+ (eff, [0.7, 0.85, 0.95, 0.9, 0.8]),
)
m.add_objective(eff, sense="max")
status, _ = m.solve(solver_name=solver_name)
@@ -1104,12 +820,8 @@ def test_disjunctive_solve(self, solver_name: str) -> None:
x = m.add_variables(name="x")
y = m.add_variables(name="y")
m.add_piecewise_constraints(
- piecewise(
- x,
- segments([[0.0, 10.0], [50.0, 100.0]]),
- segments([[0.0, 5.0], [20.0, 80.0]]),
- )
- == y,
+ (x, segments([[0.0, 10.0], [50.0, 100.0]])),
+ (y, segments([[0.0, 5.0], [20.0, 80.0]])),
)
m.add_constraints(x >= 60, name="x_min")
m.add_objective(y)
@@ -1121,12 +833,12 @@ def test_disjunctive_solve(self, solver_name: str) -> None:
# ===========================================================================
-# Solver integration – LP formulation (any solver)
+# Solver integration -- Envelope (any solver)
# ===========================================================================
@pytest.mark.skipif(len(_any_solvers) == 0, reason="No solver available")
-class TestSolverLP:
+class TestSolverTangentLines:
@pytest.fixture(params=_any_solvers)
def solver_name(self, request: pytest.FixtureRequest) -> str:
return request.param
@@ -1137,10 +849,10 @@ def test_concave_le(self, solver_name: str) -> None:
x = m.add_variables(lower=0, upper=100, name="x")
y = m.add_variables(name="y")
# Concave: [0,0],[50,40],[100,60]
- m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 40, 60]) >= y,
- )
+ env = tangent_lines(x, [0, 50, 100], [0, 40, 60])
+ m.add_constraints(y <= env, name="pwl")
m.add_constraints(x <= 75, name="x_max")
+ m.add_constraints(x >= 0, name="x_lo")
m.add_objective(y, sense="max")
status, _ = m.solve(solver_name=solver_name)
assert status == "ok"
@@ -1154,9 +866,8 @@ def test_convex_ge(self, solver_name: str) -> None:
x = m.add_variables(lower=0, upper=100, name="x")
y = m.add_variables(name="y")
# Convex: [0,0],[50,10],[100,60]
- m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 10, 60]) <= y,
- )
+ env = tangent_lines(x, [0, 50, 100], [0, 10, 60])
+ m.add_constraints(y >= env, name="pwl")
m.add_constraints(x >= 25, name="x_min")
m.add_objective(y)
status, _ = m.solve(solver_name=solver_name)
@@ -1171,10 +882,10 @@ def test_slopes_equivalence(self, solver_name: str) -> None:
m1 = Model()
x1 = m1.add_variables(lower=0, upper=100, name="x")
y1 = m1.add_variables(name="y")
- m1.add_piecewise_constraints(
- piecewise(x1, [0, 50, 100], [0, 40, 60]) >= y1,
- )
+ env1 = tangent_lines(x1, [0, 50, 100], [0, 40, 60])
+ m1.add_constraints(y1 <= env1, name="pwl")
m1.add_constraints(x1 <= 75, name="x_max")
+ m1.add_constraints(x1 >= 0, name="x_lo")
m1.add_objective(y1, sense="max")
s1, _ = m1.solve(solver_name=solver_name)
@@ -1182,15 +893,14 @@ def test_slopes_equivalence(self, solver_name: str) -> None:
m2 = Model()
x2 = m2.add_variables(lower=0, upper=100, name="x")
y2 = m2.add_variables(name="y")
- m2.add_piecewise_constraints(
- piecewise(
- x2,
- [0, 50, 100],
- breakpoints(slopes=[0.8, 0.4], x_points=[0, 50, 100], y0=0),
- )
- >= y2,
+ env2 = tangent_lines(
+ x2,
+ [0, 50, 100],
+ breakpoints(slopes=[0.8, 0.4], x_points=[0, 50, 100], y0=0),
)
+ m2.add_constraints(y2 <= env2, name="pwl")
m2.add_constraints(x2 <= 75, name="x_max")
+ m2.add_constraints(x2 >= 0, name="x_lo")
m2.add_objective(y2, sense="max")
s2, _ = m2.solve(solver_name=solver_name)
@@ -1201,40 +911,6 @@ def test_slopes_equivalence(self, solver_name: str) -> None:
)
-class TestLPDomainConstraints:
- """Tests for LP domain bound constraints."""
-
- def test_lp_domain_constraints_created(self) -> None:
- """LP method creates domain bound constraints."""
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- # Concave: slopes decreasing → y <= pw uses LP
- m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 40, 60]) >= y,
- )
- assert f"pwl0{PWL_LP_DOMAIN_SUFFIX}_lo" in m.constraints
- assert f"pwl0{PWL_LP_DOMAIN_SUFFIX}_hi" in m.constraints
-
- def test_lp_domain_constraints_multidim(self) -> None:
- """Domain constraints have entity dimension for per-entity breakpoints."""
- m = Model()
- x = m.add_variables(coords=[pd.Index(["a", "b"], name="entity")], name="x")
- y = m.add_variables(coords=[pd.Index(["a", "b"], name="entity")], name="y")
- x_pts = breakpoints({"a": [0, 50, 100], "b": [10, 60, 110]}, dim="entity")
- y_pts = breakpoints({"a": [0, 40, 60], "b": [5, 35, 55]}, dim="entity")
- m.add_piecewise_constraints(
- piecewise(x, x_pts, y_pts) >= y,
- )
- lo_name = f"pwl0{PWL_LP_DOMAIN_SUFFIX}_lo"
- hi_name = f"pwl0{PWL_LP_DOMAIN_SUFFIX}_hi"
- assert lo_name in m.constraints
- assert hi_name in m.constraints
- # Domain constraints should have the entity dimension
- assert "entity" in m.constraints[lo_name].labels.dims
- assert "entity" in m.constraints[hi_name].labels.dims
-
-
# ===========================================================================
# Active parameter (commitment binary)
# ===========================================================================
@@ -1249,7 +925,9 @@ def test_incremental_creates_active_bound(self) -> None:
y = m.add_variables(name="y")
u = m.add_variables(binary=True, name="u")
m.add_piecewise_constraints(
- piecewise(x, [0, 10, 50, 100], [5, 2, 20, 80], active=u) == y,
+ (x, [0, 10, 50, 100]),
+ (y, [5, 10, 20, 80]),
+ active=u,
method="incremental",
)
assert f"pwl0{PWL_ACTIVE_BOUND_SUFFIX}" in m.constraints
@@ -1261,47 +939,12 @@ def test_active_none_is_default(self) -> None:
x = m.add_variables(name="x")
y = m.add_variables(name="y")
m.add_piecewise_constraints(
- piecewise(x, [0, 10, 50], [0, 5, 30]) == y,
+ (x, [0, 10, 50]),
+ (y, [0, 5, 30]),
method="incremental",
)
assert f"pwl0{PWL_ACTIVE_BOUND_SUFFIX}" not in m.constraints
- def test_active_with_lp_method_raises(self) -> None:
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- u = m.add_variables(binary=True, name="u")
- with pytest.raises(ValueError, match="not supported with method='lp'"):
- m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 40, 60], active=u) >= y,
- method="lp",
- )
-
- def test_active_with_auto_lp_raises(self) -> None:
- """Auto selects LP for concave >=, but active is incompatible."""
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- u = m.add_variables(binary=True, name="u")
- with pytest.raises(ValueError, match="not supported with method='lp'"):
- m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 40, 60], active=u) >= y,
- )
-
- def test_incremental_inequality_with_active(self) -> None:
- """Inequality + active creates aux variable and active bound."""
- m = Model()
- x = m.add_variables(name="x")
- y = m.add_variables(name="y")
- u = m.add_variables(binary=True, name="u")
- m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 10, 50], active=u) >= y,
- method="incremental",
- )
- assert f"pwl0{PWL_AUX_SUFFIX}" in m.variables
- assert f"pwl0{PWL_ACTIVE_BOUND_SUFFIX}" in m.constraints
- assert "pwl0_ineq" in m.constraints
-
def test_active_with_linear_expression(self) -> None:
"""Active can be a LinearExpression, not just a Variable."""
m = Model()
@@ -1309,14 +952,16 @@ def test_active_with_linear_expression(self) -> None:
y = m.add_variables(name="y")
u = m.add_variables(binary=True, name="u")
m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 10, 50], active=1 * u) == y,
+ (x, [0, 50, 100]),
+ (y, [0, 10, 50]),
+ active=1 * u,
method="incremental",
)
assert f"pwl0{PWL_ACTIVE_BOUND_SUFFIX}" in m.constraints
# ===========================================================================
-# Solver integration – active parameter
+# Solver integration -- active parameter
# ===========================================================================
@@ -1333,7 +978,9 @@ def test_incremental_active_on(self, solver_name: str) -> None:
y = m.add_variables(name="y")
u = m.add_variables(binary=True, name="u")
m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 10, 50], active=u) == y,
+ (x, [0, 50, 100]),
+ (y, [0, 10, 50]),
+ active=u,
method="incremental",
)
m.add_constraints(u >= 1, name="force_on")
@@ -1351,7 +998,9 @@ def test_incremental_active_off(self, solver_name: str) -> None:
y = m.add_variables(name="y")
u = m.add_variables(binary=True, name="u")
m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 10, 50], active=u) == y,
+ (x, [0, 50, 100]),
+ (y, [0, 10, 50]),
+ active=u,
method="incremental",
)
m.add_constraints(u <= 0, name="force_off")
@@ -1363,9 +1012,9 @@ def test_incremental_active_off(self, solver_name: str) -> None:
def test_incremental_nonzero_base_active_off(self, solver_name: str) -> None:
"""
- Non-zero base (x₀=20, y₀=5) with u=0 must still force zero.
+ Non-zero base (x0=20, y0=5) with u=0 must still force zero.
- Tests the x₀*u / y₀*u base term multiplication — would fail if
+ Tests the x0*u / y0*u base term multiplication -- would fail if
base terms aren't multiplied by active.
"""
m = Model()
@@ -1373,7 +1022,9 @@ def test_incremental_nonzero_base_active_off(self, solver_name: str) -> None:
y = m.add_variables(name="y")
u = m.add_variables(binary=True, name="u")
m.add_piecewise_constraints(
- piecewise(x, [20, 60, 100], [5, 20, 50], active=u) == y,
+ (x, [20, 60, 100]),
+ (y, [5, 20, 50]),
+ active=u,
method="incremental",
)
m.add_constraints(u <= 0, name="force_off")
@@ -1383,22 +1034,6 @@ def test_incremental_nonzero_base_active_off(self, solver_name: str) -> None:
np.testing.assert_allclose(float(x.solution.values), 0, atol=1e-4)
np.testing.assert_allclose(float(y.solution.values), 0, atol=1e-4)
- def test_incremental_inequality_active_off(self, solver_name: str) -> None:
- """Inequality with active=0: aux variable is 0, so y <= 0."""
- m = Model()
- x = m.add_variables(lower=0, upper=100, name="x")
- y = m.add_variables(lower=0, name="y")
- u = m.add_variables(binary=True, name="u")
- m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 10, 50], active=u) >= y,
- method="incremental",
- )
- m.add_constraints(u <= 0, name="force_off")
- m.add_objective(y, sense="max")
- status, _ = m.solve(solver_name=solver_name)
- assert status == "ok"
- np.testing.assert_allclose(float(y.solution.values), 0, atol=1e-4)
-
def test_unit_commitment_pattern(self, solver_name: str) -> None:
"""Solver decides to commit: verifies correct fuel at operating point."""
m = Model()
@@ -1410,8 +1045,9 @@ def test_unit_commitment_pattern(self, solver_name: str) -> None:
u = m.add_variables(binary=True, name="commit")
m.add_piecewise_constraints(
- piecewise(power, [p_min, p_max], [fuel_at_pmin, fuel_at_pmax], active=u)
- == fuel,
+ (power, [p_min, p_max]),
+ (fuel, [fuel_at_pmin, fuel_at_pmax]),
+ active=u,
method="incremental",
)
m.add_constraints(power >= 50, name="demand")
@@ -1432,7 +1068,9 @@ def test_multi_dimensional_solver(self, solver_name: str) -> None:
y = m.add_variables(coords=[gens], name="y")
u = m.add_variables(binary=True, coords=[gens], name="u")
m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 10, 50], active=u) == y,
+ (x, [0, 50, 100]),
+ (y, [0, 10, 50]),
+ active=u,
method="incremental",
)
m.add_constraints(u.sel(gen="a") >= 1, name="a_on")
@@ -1454,13 +1092,15 @@ def solver_name(self, request: pytest.FixtureRequest) -> str:
return request.param
def test_sos2_active_off(self, solver_name: str) -> None:
- """SOS2: u=0 forces Σλ=0, collapsing x=0, y=0."""
+ """SOS2: u=0 forces sum(lambda)=0, collapsing x=0, y=0."""
m = Model()
x = m.add_variables(lower=0, upper=100, name="x")
y = m.add_variables(name="y")
u = m.add_variables(binary=True, name="u")
m.add_piecewise_constraints(
- piecewise(x, [0, 50, 100], [0, 10, 50], active=u) == y,
+ (x, [0, 50, 100]),
+ (y, [0, 10, 50]),
+ active=u,
method="sos2",
)
m.add_constraints(u <= 0, name="force_off")
@@ -1471,19 +1111,15 @@ def test_sos2_active_off(self, solver_name: str) -> None:
np.testing.assert_allclose(float(y.solution.values), 0, atol=1e-4)
def test_disjunctive_active_off(self, solver_name: str) -> None:
- """Disjunctive: u=0 forces Σz_k=0, collapsing x=0, y=0."""
+ """Disjunctive: u=0 forces sum(z_k)=0, collapsing x=0, y=0."""
m = Model()
x = m.add_variables(lower=0, upper=100, name="x")
y = m.add_variables(name="y")
u = m.add_variables(binary=True, name="u")
m.add_piecewise_constraints(
- piecewise(
- x,
- segments([[0.0, 10.0], [50.0, 100.0]]),
- segments([[0.0, 5.0], [20.0, 80.0]]),
- active=u,
- )
- == y,
+ (x, segments([[0.0, 10.0], [50.0, 100.0]])),
+ (y, segments([[0.0, 5.0], [20.0, 80.0]])),
+ active=u,
)
m.add_constraints(u <= 0, name="force_off")
m.add_objective(y, sense="max")
@@ -1491,3 +1127,246 @@ def test_disjunctive_active_off(self, solver_name: str) -> None:
assert status == "ok"
np.testing.assert_allclose(float(x.solution.values), 0, atol=1e-4)
np.testing.assert_allclose(float(y.solution.values), 0, atol=1e-4)
+
+
+# ===========================================================================
+# N-variable path
+# ===========================================================================
+
+
+class TestNVariable:
+ """Tests for the N-variable tuple-based piecewise constraint API."""
+
+ def test_sos2_creates_lambda_and_link(self) -> None:
+ m = Model()
+ power = m.add_variables(lower=0, upper=100, name="power")
+ fuel = m.add_variables(name="fuel")
+ m.add_piecewise_constraints(
+ (power, [0.0, 50.0, 100.0]),
+ (fuel, [0.0, 20.0, 60.0]),
+ method="sos2",
+ )
+ assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables
+ assert f"pwl0{PWL_CONVEX_SUFFIX}" in m.constraints
+ assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints
+
+ def test_incremental_creates_delta(self) -> None:
+ m = Model()
+ power = m.add_variables(lower=0, upper=100, name="power")
+ fuel = m.add_variables(name="fuel")
+ m.add_piecewise_constraints(
+ (power, [0.0, 50.0, 100.0]),
+ (fuel, [0.0, 20.0, 60.0]),
+ method="incremental",
+ )
+ assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables
+ assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints
+
+ def test_auto_selects_method(self) -> None:
+ m = Model()
+ power = m.add_variables(lower=0, upper=100, name="power")
+ fuel = m.add_variables(name="fuel")
+ m.add_piecewise_constraints(
+ (power, [0.0, 50.0, 100.0]),
+ (fuel, [0.0, 20.0, 60.0]),
+ )
+ # Auto should select incremental for monotonic breakpoints
+ assert f"pwl0{PWL_DELTA_SUFFIX}" in m.variables
+
+ def test_single_pair_raises(self) -> None:
+ m = Model()
+ power = m.add_variables(name="power")
+ with pytest.raises(TypeError, match="at least 2"):
+ m.add_piecewise_constraints(
+ (power, [0.0, 50.0, 100.0]),
+ )
+
+ def test_three_variables(self) -> None:
+ m = Model()
+ power = m.add_variables(lower=0, upper=100, name="power")
+ fuel = m.add_variables(name="fuel")
+ heat = m.add_variables(name="heat")
+ m.add_piecewise_constraints(
+ (power, [0.0, 50.0, 100.0]),
+ (fuel, [0.0, 20.0, 60.0]),
+ (heat, [0.0, 30.0, 80.0]),
+ method="sos2",
+ )
+ assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables
+ assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints
+ # link constraint should have _pwl_var dimension
+ link = m.constraints[f"pwl0{PWL_X_LINK_SUFFIX}"]
+ assert "_pwl_var" in link.labels.dims
+
+ def test_custom_name(self) -> None:
+ m = Model()
+ power = m.add_variables(lower=0, upper=100, name="power")
+ fuel = m.add_variables(name="fuel")
+ m.add_piecewise_constraints(
+ (power, [0.0, 50.0, 100.0]),
+ (fuel, [0.0, 20.0, 60.0]),
+ name="chp",
+ )
+ assert f"chp{PWL_DELTA_SUFFIX}" in m.variables
+
+
+# ===========================================================================
+# Additional validation and edge-case coverage
+# ===========================================================================
+
+
+class TestValidationEdgeCases:
+ def test_non_1d_sequence_raises(self) -> None:
+ """breakpoints() with a 2D nested list raises ValueError."""
+ with pytest.raises(ValueError, match="1D sequence"):
+ breakpoints([[1, 2], [3, 4]])
+
+ def test_breakpoints_no_values_no_slopes_raises(self) -> None:
+ """breakpoints() with neither values nor slopes raises."""
+ with pytest.raises(ValueError, match="Must pass either"):
+ breakpoints()
+
+ def test_slopes_1d_non_scalar_y0_raises(self) -> None:
+ """1D slopes with dict y0 raises TypeError."""
+ with pytest.raises(TypeError, match="scalar float"):
+ breakpoints(slopes=[1, 2], x_points=[0, 10, 20], y0={"a": 0})
+
+ def test_slopes_bad_y0_type_raises(self) -> None:
+ """Slopes with unsupported y0 type raises TypeError."""
+ with pytest.raises(TypeError, match="y0"):
+ breakpoints(
+ slopes={"a": [1, 2], "b": [3, 4]},
+ x_points={"a": [0, 10, 20], "b": [0, 10, 20]},
+ y0="bad",
+ dim="entity",
+ )
+
+ def test_slopes_dataarray_y0(self) -> None:
+ """Slopes mode with DataArray y0 works."""
+ y0_da = xr.DataArray([0, 5], dims=["gen"], coords={"gen": ["a", "b"]})
+ bp = breakpoints(
+ slopes={"a": [1, 2], "b": [3, 4]},
+ x_points={"a": [0, 10, 20], "b": [0, 10, 20]},
+ y0=y0_da,
+ dim="gen",
+ )
+ assert BREAKPOINT_DIM in bp.dims
+ assert "gen" in bp.dims
+
+ def test_non_numeric_breakpoint_coords_raises(self) -> None:
+ """SOS2 with string breakpoint coords raises ValueError."""
+ m = Model()
+ x = m.add_variables(name="x")
+ y = m.add_variables(name="y")
+ x_pts = xr.DataArray(
+ [0, 10, 50],
+ dims=[BREAKPOINT_DIM],
+ coords={BREAKPOINT_DIM: ["a", "b", "c"]},
+ )
+ y_pts = xr.DataArray(
+ [0, 5, 20],
+ dims=[BREAKPOINT_DIM],
+ coords={BREAKPOINT_DIM: ["a", "b", "c"]},
+ )
+ with pytest.raises(ValueError, match="numeric coordinates"):
+ m.add_piecewise_constraints(
+ (x, x_pts),
+ (y, y_pts),
+ method="sos2",
+ )
+
+ def test_missing_breakpoint_dim_on_second_arg_raises(self) -> None:
+ """Second breakpoint array missing breakpoint dim raises."""
+ m = Model()
+ x = m.add_variables(name="x")
+ y = m.add_variables(name="y")
+ good = xr.DataArray([0, 10, 50], dims=[BREAKPOINT_DIM])
+ bad = xr.DataArray([0, 5, 20], dims=["wrong"])
+ with pytest.raises(ValueError, match="missing"):
+ m.add_piecewise_constraints((x, good), (y, bad))
+
+ def test_segment_dim_mismatch_raises(self) -> None:
+ """Segment dim on only one breakpoint array raises."""
+ m = Model()
+ x = m.add_variables(name="x")
+ y = m.add_variables(name="y")
+ x_pts = segments([[0, 10], [50, 100]])
+ y_pts = breakpoints([0, 5]) # same breakpoint count but no segment dim
+ with pytest.raises(ValueError, match="segment dimension"):
+ m.add_piecewise_constraints((x, x_pts), (y, y_pts))
+
+ def test_disjunctive_three_pairs(self) -> None:
+ """Disjunctive with 3 pairs works (N-variable)."""
+ m = Model()
+ x = m.add_variables(name="x")
+ y = m.add_variables(name="y")
+ z = m.add_variables(name="z")
+ seg = segments([[0, 10], [50, 100]])
+ m.add_piecewise_constraints(
+ (x, seg),
+ (y, seg),
+ (z, seg),
+ )
+ assert f"pwl0{PWL_BINARY_SUFFIX}" in m.variables
+ assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables
+ assert f"pwl0{PWL_X_LINK_SUFFIX}" in m.constraints
+
+ def test_disjunctive_interior_nan_raises(self) -> None:
+ """Disjunctive with interior NaN raises ValueError."""
+ m = Model()
+ x = m.add_variables(name="x")
+ y = m.add_variables(name="y")
+ # 3 breakpoints per segment, NaN in the middle of segment 0
+ x_pts = xr.DataArray(
+ [[0, np.nan, 10], [50, 75, 100]],
+ dims=[SEGMENT_DIM, BREAKPOINT_DIM],
+ )
+ y_pts = xr.DataArray(
+ [[0, np.nan, 5], [20, 50, 80]],
+ dims=[SEGMENT_DIM, BREAKPOINT_DIM],
+ )
+ with pytest.raises(ValueError, match="non-trailing NaN"):
+ m.add_piecewise_constraints((x, x_pts), (y, y_pts))
+
+ def test_expression_name_fallback(self) -> None:
+ """LinExpr (not Variable) gets numeric name in link coords."""
+ m = Model()
+ x = m.add_variables(name="x")
+ y = m.add_variables(name="y")
+ # Non-monotonic so auto picks SOS2 (which creates lambda vars)
+ m.add_piecewise_constraints(
+ (1.0 * x, [0, 50, 10]),
+ (1.0 * y, [0, 20, 5]),
+ method="sos2",
+ )
+ assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables
+
+ def test_incremental_with_nan_mask(self) -> None:
+ """Incremental method with trailing NaN creates masked delta vars."""
+ m = Model()
+ gens = pd.Index(["a", "b"], name="gen")
+ x = m.add_variables(coords=[gens], name="x")
+ y = m.add_variables(coords=[gens], name="y")
+ x_pts = breakpoints({"a": [0, 10, 50], "b": [0, 20]}, dim="gen")
+ y_pts = breakpoints({"a": [0, 5, 20], "b": [0, 8]}, dim="gen")
+ m.add_piecewise_constraints(
+ (x, x_pts),
+ (y, y_pts),
+ method="incremental",
+ )
+ delta = m.variables[f"pwl0{PWL_DELTA_SUFFIX}"]
+ assert delta.labels.shape[0] == 2 # 2 generators
+
+ def test_scalar_coord_dropped(self) -> None:
+ """Scalar coords on breakpoints are dropped before stacking."""
+ m = Model()
+ x = m.add_variables(name="x")
+ y = m.add_variables(name="y")
+ bp = breakpoints([0, 10, 50])
+ bp_with_scalar = bp.assign_coords(extra=42)
+ m.add_piecewise_constraints(
+ (x, bp_with_scalar),
+ (y, [0, 5, 20]),
+ method="sos2",
+ )
+ assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables