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", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
commitpowerfuelbackup
time
10.00.00.00000015.0
21.070.0110.0000000.0
31.050.073.3333330.0
\n", - "
" - ] - }, - "execution_count": 48, - "metadata": {}, - "output_type": "execute_result" + "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