From 277cda8bbe5ec02f3618570f698bf423bb1b3f89 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 May 2026 13:31:16 +0200 Subject: [PATCH 01/20] feat(piecewise): add Slopes class for deferred breakpoint specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces ``linopy.Slopes`` — a frozen dataclass that carries per-piece slopes + initial y-value, deferred until an x grid is known. Used as the second element of a tuple in ``add_piecewise_formulation`` where another tuple in the same call provides the x grid:: m.add_piecewise_formulation( (power, [0, 30, 60, 100]), (fuel, Slopes([1.2, 1.4, 1.7], y0=0)), ) * Constructor: ``Slopes(values, y0=0.0, align="pieces", dim=None)`` * Standalone resolution: ``Slopes(...).to_breakpoints(x_points)`` returns the resolved breakpoint ``DataArray`` — useful for inspection or building breakpoints outside the formulation pipeline. * Dispatch: ``add_piecewise_formulation`` adds a one-pass resolution that borrows the x grid from the first non-Slopes tuple (deterministic). All-Slopes calls raise with a pointer to the standalone resolution. * Supports the same shape variations as ``breakpoints(slopes=...)`` (1D, dict, DataFrame, DataArray) and the ``align`` modes from #672. This commit is purely additive: ``breakpoints(slopes=..., x_points=..., y0=...)`` and ``slopes_to_points`` keep working unchanged. A follow-up commit removes them in favour of ``Slopes``. Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/__init__.py | 2 + linopy/piecewise.py | 104 ++++++++++++++++++++- test/test_piecewise_constraints.py | 143 +++++++++++++++++++++++++++++ 3 files changed, 246 insertions(+), 3 deletions(-) diff --git a/linopy/__init__.py b/linopy/__init__.py index 220eee3c..b82035a4 100644 --- a/linopy/__init__.py +++ b/linopy/__init__.py @@ -27,6 +27,7 @@ from linopy.objective import Objective from linopy.piecewise import ( PiecewiseFormulation, + Slopes, breakpoints, segments, slopes_to_points, @@ -53,6 +54,7 @@ "PiecewiseFormulation", "QuadraticExpression", "RemoteHandler", + "Slopes", "Variable", "Variables", "align", diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 5918fea7..147d95cf 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -89,6 +89,83 @@ def _warn_evolving_api(key: _EvolvingApiKey, message: str) -> None: ) +# --------------------------------------------------------------------------- +# Deferred slopes spec +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class Slopes: + """ + Per-piece slopes + initial y-value, deferred until an x grid is known. + + Used as the second element of a tuple in + :func:`add_piecewise_formulation` where exactly one *other* tuple + provides the x grid for all :class:`Slopes` tuples in the call:: + + m.add_piecewise_formulation( + (power, [0, 30, 60, 100]), # provides x grid + (fuel, Slopes([1.2, 1.4, 1.7], y0=0)), # x grid borrowed + ) + + Standalone use is possible via :meth:`to_breakpoints`, which resolves + the spec to an ordinary breakpoint :class:`xarray.DataArray` given an + explicit x grid. + + Parameters + ---------- + values : BreaksLike + Per-piece slopes. 1D for shared breakpoints; 2D (DataFrame / + dict / DataArray with entity dim) for per-entity slopes. + y0 : float, dict, pd.Series, or DataArray, default 0.0 + y-value at the first breakpoint. Scalar broadcasts to all + entities; dict/Series/DataArray provides per-entity values. + align : {"pieces", "leading"}, default "pieces" + Alignment of ``values`` relative to the x grid. + + - ``"pieces"``: ``len(values) == len(x_points) - 1``; + ``values[i]`` is the slope between ``x[i]`` and ``x[i+1]``. + - ``"leading"``: ``len(values) == len(x_points)``; ``values[0]`` + must be NaN and is dropped, ``values[i]`` for ``i>=1`` is the + slope between ``x[i-1]`` and ``x[i]``. Useful when a marginal + value is tabulated alongside each breakpoint with the first + row's marginal undefined. + dim : str, optional + Entity dimension name. Required when ``values`` is a + ``pd.DataFrame`` or ``dict``. + + Warns + ----- + EvolvingAPIWarning + :class:`Slopes` is part of the newly-added piecewise API. Its + constructor signature and dispatch semantics may be refined. + Silence with ``warnings.filterwarnings("ignore", + category=linopy.EvolvingAPIWarning)``. + """ + + values: BreaksLike + y0: float | dict[str, float] | pd.Series | DataArray = 0.0 + align: Literal["pieces", "leading"] = "pieces" + dim: str | None = None + + def to_breakpoints(self, x_points: BreaksLike) -> DataArray: + """ + Resolve to a breakpoint :class:`xarray.DataArray`, given an x grid. + + Rarely called directly — typically you pass the :class:`Slopes` + instance to :func:`add_piecewise_formulation` and the x grid is + inherited from a sibling tuple. Use this method for inspection + or when building breakpoints outside the formulation pipeline. + """ + return _breakpoints_from_slopes( + self.values, x_points, self.y0, self.dim, self.align + ) + + +# Tuple element type covering both eager (DataArray etc.) and deferred (Slopes) bps. +BreaksOrSlopes: TypeAlias = BreaksLike | Slopes + + # --------------------------------------------------------------------------- # Result type # --------------------------------------------------------------------------- @@ -848,8 +925,8 @@ def _broadcast_points( def add_piecewise_formulation( model: Model, - *pairs: tuple[LinExprLike, BreaksLike] - | tuple[LinExprLike, BreaksLike, Literal["==", "<=", ">="]], + *pairs: tuple[LinExprLike, BreaksOrSlopes] + | tuple[LinExprLike, BreaksOrSlopes, Literal["==", "<=", ">="]], method: PWL_METHOD = "auto", active: LinExprLike | None = None, name: str | None = None, @@ -984,7 +1061,7 @@ def add_piecewise_formulation( # Parse and normalise per-tuple signs. Each pair is either # (expr, bp) — sign defaults to "==" — or (expr, bp, sign). - parsed: list[tuple[LinExprLike, BreaksLike, str]] = [] + parsed: list[tuple[LinExprLike, BreaksOrSlopes, str]] = [] for i, pair in enumerate(pairs): if not isinstance(pair, tuple) or len(pair) not in (2, 3): raise TypeError( @@ -1004,6 +1081,27 @@ def add_piecewise_formulation( ) parsed.append((expr, bp, tuple_sign)) + # Resolve any deferred Slopes tuples by borrowing the x grid from the + # first non-Slopes tuple. All non-Slopes tuples share the same + # BREAKPOINT_DIM (validated downstream), so picking the first is + # unambiguous. + slopes_idx = [i for i, p in enumerate(parsed) if isinstance(p[1], Slopes)] + if slopes_idx: + non_slopes_idx = [i for i in range(len(parsed)) if i not in set(slopes_idx)] + if not non_slopes_idx: + raise ValueError( + "All tuples are Slopes; at least one tuple must carry an " + "explicit x grid. Pass the x grid via a regular tuple " + "or call Slopes(...).to_breakpoints(x_pts) explicitly." + ) + x_grid = parsed[non_slopes_idx[0]][1] + parsed = [ + (expr, bp.to_breakpoints(x_grid), sign) + if isinstance(bp, Slopes) + else (expr, bp, sign) + for expr, bp, sign in parsed + ] + # At most one non-equality sign; with 3+ tuples, none. bounded_positions = [i for i, p in enumerate(parsed) if p[2] != EQUAL] if len(bounded_positions) > 1: diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 2fa4e7bb..e7d4651b 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -282,6 +282,149 @@ def test_without_slopes_mode_raises(self) -> None: breakpoints([0, 1, 2], slopes_align="leading") +# =========================================================================== +# Slopes class — deferred breakpoint spec +# =========================================================================== + + +class TestSlopesClass: + """Direct unit tests for the ``Slopes`` value type.""" + + def test_to_breakpoints_1d(self) -> None: + from linopy import Slopes + + s = Slopes([1.2, 1.4, 1.7], y0=0) + bp = s.to_breakpoints([0, 30, 60, 100]) + # y0=0; piece increments: 30*1.2=36, 30*1.4=42, 40*1.7=68 + # cumulative: 0, 36, 78, 146 + assert list(bp.values) == pytest.approx([0, 36, 78, 146]) + + def test_to_breakpoints_per_entity_dict(self) -> None: + from linopy import Slopes + + s = Slopes({"a": [1, 0.5], "b": [2, 1]}, y0={"a": 0, "b": 10}, dim="gen") + bp = s.to_breakpoints({"a": [0, 10, 20], "b": [0, 5, 10]}) + # a: y0=0, +10*1=10, +10*0.5=15 -> [0, 10, 15] + # b: y0=10, +5*2=20, +5*1=25 -> [10, 20, 25] + assert "gen" in bp.dims + assert bp.sel(gen="a").values.tolist() == pytest.approx([0, 10, 15]) + assert bp.sel(gen="b").values.tolist() == pytest.approx([10, 20, 25]) + + def test_to_breakpoints_align_leading(self) -> None: + from linopy import Slopes + + # Same as align="pieces" with [1, 2], plus a leading NaN to drop. + s = Slopes([np.nan, 1, 2], y0=0, align="leading") + bp = s.to_breakpoints([0, 1, 2]) + assert list(bp.values) == pytest.approx([0, 1, 3]) + + def test_align_leading_first_not_nan_raises(self) -> None: + from linopy import Slopes + + with pytest.raises(ValueError, match="first slope"): + Slopes([1, 2, 3], y0=0, align="leading").to_breakpoints([0, 1, 2]) + + def test_immutable(self) -> None: + from linopy import Slopes + + s = Slopes([1, 2], y0=0) + with pytest.raises((AttributeError, TypeError)): + s.y0 = 5 # type: ignore[misc] + + +class TestSlopesDispatch: + """Slopes inside ``add_piecewise_formulation`` — sibling resolution.""" + + def test_two_tuple_deferred(self) -> None: + from linopy import Slopes + + m = Model() + power = m.add_variables(lower=0, upper=100, name="power") + fuel = m.add_variables(lower=0, name="fuel") + # Slopes [1.2, 1.4, 1.7] resolved over the borrowed x grid + # [0, 30, 60, 100] -> fuel breakpoints [0, 36, 78, 146]. + # Equality-2-tuple convexity uses pinned_bps[1] as x; with + # increasing dy/dx slopes, the inverse view (power-vs-fuel) is + # concave — that's the label the formulation reports. + f = m.add_piecewise_formulation( + (power, [0, 30, 60, 100]), + (fuel, Slopes([1.2, 1.4, 1.7], y0=0)), + ) + assert f.method in ("sos2", "incremental") + assert f.convexity == "concave" + + def test_three_tuple_deferred(self) -> None: + """Slopes pulls x grid even with another non-Slopes tuple present.""" + from linopy import Slopes + + m = Model() + power = m.add_variables(name="power") + fuel = m.add_variables(name="fuel") + heat = m.add_variables(name="heat") + f = m.add_piecewise_formulation( + (power, [0, 30, 60, 100]), + (fuel, [0, 40, 85, 160]), + (heat, Slopes([0.8, 1.0, 1.0], y0=0)), + ) + # 3-var formulation -> convexity is None + assert f.convexity is None + + def test_slopes_as_bounded_tuple(self) -> None: + from linopy import Slopes + + m = Model() + x = m.add_variables(lower=0, upper=30, name="x") + y = m.add_variables(lower=0, upper=100, name="y") + f = m.add_piecewise_formulation( + (y, Slopes([2, 1, 0.5], y0=0), "<="), # concave + (x, [0, 10, 20, 30]), + ) + assert f.method == "lp" + assert f.convexity == "concave" + + def test_all_slopes_raises(self) -> None: + from linopy import Slopes + + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + with pytest.raises(ValueError, match="All tuples are Slopes"): + m.add_piecewise_formulation( + (x, Slopes([1, 2], y0=0)), + (y, Slopes([1, 1], y0=0)), + ) + + def test_two_non_slopes_picks_first_x_grid(self) -> None: + """With multiple non-Slopes tuples, deterministic pick from the first.""" + from linopy import Slopes + + m = Model() + x = m.add_variables(name="x") + y = m.add_variables(name="y") + z = m.add_variables(name="z") + # x and y both carry the same grid (size 4); z borrows it + f = m.add_piecewise_formulation( + (x, [0, 30, 60, 100]), + (y, [0, 40, 85, 160]), + (z, Slopes([0.5, 0.6, 0.7], y0=0)), + ) + assert f.name in m._piecewise_formulations + + def test_slopes_align_leading_in_dispatch(self) -> None: + from linopy import Slopes + + m = Model() + x = m.add_variables(lower=0, upper=2, name="x") + y = m.add_variables(name="y") + f = m.add_piecewise_formulation( + (x, [0, 1, 2]), + (y, Slopes([np.nan, 1, 2], y0=0, align="leading")), + ) + # Resolved bp for y: [0, 1, 3]. As above, the equality-2-tuple + # convention reports the inverse view → concave. + assert f.convexity == "concave" + + # =========================================================================== # segments() factory # =========================================================================== From c592b740bdb516f9de8e91e605db836910e6586c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 May 2026 13:39:41 +0200 Subject: [PATCH 02/20] refactor(piecewise): remove slopes-mode of breakpoints() and slopes_to_points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that ``Slopes`` covers the deferred-and-standalone slopes use case with a clearer type story, drop the duplicated paths: * ``breakpoints(slopes=, x_points=, y0=, slopes_align=)`` removed. ``breakpoints`` is now points-only: ``breakpoints(values, *, dim=None)``. * ``slopes_to_points`` made private (``_slopes_to_points``) — it's a list-level primitive used only by ``Slopes.to_breakpoints``. Public callers should use ``Slopes(...)``; users who need list output can call ``Slopes(...).to_breakpoints([...]).values.tolist()``. Both surfaces shipped earlier in this development cycle (``Slopes`` mode of ``breakpoints`` from #602 and #672, ``slopes_to_points`` from #602) and have not been released, so the breakage window is the same as the rest of the v0.7.0 piecewise work. Tests migrated: * The slopes-mode tests on ``TestBreakpointsFactory`` and the entire ``TestSlopesAlignLeading`` class are removed; the same shapes are exercised in expanded ``TestSlopesClass`` tests (Series / DataArray / DataFrame / shared x grid / shared y0 / leading-align ragged / bad-y0 validation). * ``TestSlopesToPoints`` becomes ``TestSlopesToPointsPrivate``, importing the helper under its private name. * Inline ``breakpoints(slopes=...)`` callers in feasibility/envelope tests migrated to ``Slopes(...)`` (or ``Slopes(...).to_breakpoints(x_pts)`` for the standalone path). Docs: * ``doc/api.rst``: drop ``slopes_to_points``, add ``Slopes``. * ``doc/release_notes.rst``: replace the ``breakpoints`` slopes-mode bullet with one describing ``Slopes``. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/api.rst | 2 +- doc/release_notes.rst | 3 +- linopy/__init__.py | 2 - linopy/piecewise.py | 80 ++------ test/test_piecewise_constraints.py | 302 +++++++++++------------------ 5 files changed, 130 insertions(+), 259 deletions(-) diff --git a/doc/api.rst b/doc/api.rst index 07eebfeb..1fd5cb64 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -20,9 +20,9 @@ Creating a model model.Model.add_objective model.Model.add_piecewise_formulation piecewise.PiecewiseFormulation + piecewise.Slopes piecewise.breakpoints piecewise.segments - piecewise.slopes_to_points piecewise.tangent_lines model.Model.linexpr model.Model.remove_constraints diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 52d7526a..216c643e 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -16,7 +16,8 @@ Upcoming Version * Add unit-commitment gating via the ``active`` parameter on ``add_piecewise_formulation``: a binary variable that, when zero, forces all auxiliary variables (and thus the linked expressions) to zero. Works with the SOS2, incremental, and disjunctive methods. * Surface formulation metadata on the returned ``PiecewiseFormulation``: ``.method`` (resolved method name) and ``.convexity`` (``"convex"`` / ``"concave"`` / ``"linear"`` / ``"mixed"`` when well-defined). Both persist across netCDF round-trip. * Add ``tangent_lines()`` as a low-level helper that returns per-piece chord expressions as a ``LinearExpression`` — no variables created. Most users should prefer ``add_piecewise_formulation`` with a bounded tuple ``(y, y_pts, "<=")``, which builds on this helper and adds domain bounds and curvature validation. -* Add ``linopy.breakpoints()`` (lists/Series/DataFrame/DataArray/dict, plus a slopes-mode constructor), ``linopy.segments()`` (disjunctive operating regions), and ``slopes_to_points()`` (per-piece slopes → breakpoint y-coordinates) as breakpoint-construction helpers. +* Add ``linopy.breakpoints()`` (lists/Series/DataFrame/DataArray/dict) and ``linopy.segments()`` (disjunctive operating regions) as breakpoint-construction helpers. +* Add ``linopy.Slopes`` as a deferred breakpoint spec carrying per-piece slopes plus an initial y-value: ``Slopes([1.2, 1.4, 1.7], y0=0)``. Pass it as a tuple element in ``add_piecewise_formulation`` to inherit the x grid from a sibling tuple, or call ``Slopes(...).to_breakpoints(x_pts)`` for standalone resolution. Supports the same shape variations (1D, dict, DataFrame, DataArray, per-entity y0) and the ``align="leading"`` mode that ``breakpoints(slopes=...)`` covered. This **replaces** the slopes mode of ``breakpoints()`` (``slopes=``, ``x_points=``, ``y0=``, ``slopes_align=``) and the standalone ``slopes_to_points()`` helper, both shipped earlier in this development cycle and not yet released. * 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/linopy/__init__.py b/linopy/__init__.py index b82035a4..d47d3aa7 100644 --- a/linopy/__init__.py +++ b/linopy/__init__.py @@ -30,7 +30,6 @@ Slopes, breakpoints, segments, - slopes_to_points, tangent_lines, ) from linopy.remote import RemoteHandler @@ -64,6 +63,5 @@ "options", "read_netcdf", "segments", - "slopes_to_points", "tangent_lines", ) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 147d95cf..c649eb41 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -422,7 +422,7 @@ def _breakpoints_from_slopes( 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)) + 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 @@ -456,7 +456,7 @@ def _breakpoints_from_slopes( 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]) + computed[sk] = _slopes_to_points(xp, sl, y0_map[sk]) return _dict_to_array(computed, entity_dim) @@ -466,30 +466,14 @@ def _breakpoints_from_slopes( # --------------------------------------------------------------------------- -def slopes_to_points( +def _slopes_to_points( x_points: list[float], slopes: list[float], y0: float ) -> list[float]: """ Convert per-piece slopes + initial y-value to y-coordinates at each breakpoint. - Parameters - ---------- - x_points : list[float] - Breakpoint x-coordinates (length n). - slopes : list[float] - Slope of each piece (length n-1). - y0 : float - y-value at the first breakpoint. - - Returns - ------- - list[float] - y-coordinates at each breakpoint (length n). - - Raises - ------ - ValueError - If ``len(slopes) != len(x_points) - 1``. + Internal primitive used by ``Slopes.to_breakpoints``. Public callers + should use :class:`Slopes` (DataArray output) instead. """ if len(slopes) != len(x_points) - 1: raise ValueError( @@ -503,72 +487,34 @@ def slopes_to_points( def breakpoints( - values: BreaksLike | None = None, + values: BreaksLike, *, - slopes: BreaksLike | None = None, - x_points: BreaksLike | None = None, - y0: float | dict[str, float] | pd.Series | DataArray | None = None, dim: str | None = None, - slopes_align: Literal["pieces", "leading"] = "pieces", ) -> DataArray: """ Create a breakpoint DataArray for piecewise linear constraints. - Two modes (mutually exclusive): - - **Points mode**: ``breakpoints(values, ...)`` - - **Slopes mode**: ``breakpoints(slopes=..., x_points=..., y0=...)`` - Parameters ---------- - values : BreaksLike, optional + values : BreaksLike Breakpoint values. Accepted types: ``Sequence[float]``, ``pd.Series``, ``pd.DataFrame``, or ``xr.DataArray``. A 1D input (list, Series) creates 1D breakpoints. A 2D input (DataFrame, multi-dim DataArray) creates per-entity breakpoints (``dim`` is required for DataFrame). - slopes : BreaksLike, optional - Segment slopes. Mutually exclusive with ``values``. - x_points : BreaksLike, optional - Breakpoint x-coordinates. Required with ``slopes``. - y0 : float, dict, pd.Series, or DataArray, optional - Initial y-value. Required with ``slopes``. A scalar broadcasts to - all entities. A dict/Series/DataArray provides per-entity values. dim : str, optional - Entity dimension name. Required when ``values`` or ``slopes`` is a + Entity dimension name. Required when ``values`` is a ``pd.DataFrame`` or ``dict``. - slopes_align : {"pieces", "leading"}, default "pieces" - Alignment of ``slopes`` relative to ``x_points``. - - - ``"pieces"``: ``len(slopes) == len(x_points) - 1``. ``slopes[i]`` - is the slope between ``x[i]`` and ``x[i+1]``. - - ``"leading"``: ``len(slopes) == len(x_points)``. ``slopes[0]`` - must be NaN and is ignored; ``slopes[i]`` for ``i>=1`` is the - slope between ``x[i-1]`` and ``x[i]``. Useful when a marginal - value is tabulated alongside each breakpoint with the first - row's marginal undefined. Returns ------- DataArray - """ - # Validate mutual exclusivity - if values is not None and slopes is not None: - raise ValueError("'values' and 'slopes' are mutually exclusive") - if values is not None and (x_points is not None or y0 is not None): - raise ValueError("'x_points' and 'y0' are forbidden when 'values' is given") - if slopes_align != "pieces" and slopes is None: - raise ValueError("'slopes_align' is only valid in slopes mode") - if slopes is not None: - if x_points is None or y0 is None: - raise ValueError("'slopes' requires both 'x_points' and 'y0'") - return _breakpoints_from_slopes(slopes, x_points, y0, dim, slopes_align) - - # Points mode - if values is None: - raise ValueError("Must pass either 'values' or 'slopes'") + See Also + -------- + Slopes : per-piece slopes + ``y0`` (deferred or standalone via + :meth:`Slopes.to_breakpoints`). + """ return _coerce_breaks(values, dim) diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index e7d4651b..5b371c64 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -18,7 +18,6 @@ available_solvers, breakpoints, segments, - slopes_to_points, tangent_lines, ) from linopy.constants import ( @@ -58,21 +57,32 @@ # =========================================================================== -# slopes_to_points +# _slopes_to_points (private list utility) # =========================================================================== -class TestSlopesToPoints: +class TestSlopesToPointsPrivate: + """ + The list-level slopes→points primitive is private; the public path is + :class:`Slopes`. These tests exist so the math stays under test even + though the helper isn't user-facing. + """ + def test_basic(self) -> None: - assert slopes_to_points([0, 1, 2], [1, 2], 0) == [0, 1, 3] + from linopy.piecewise import _slopes_to_points + + assert _slopes_to_points([0, 1, 2], [1, 2], 0) == [0, 1, 3] def test_negative_slopes(self) -> None: - result = slopes_to_points([0, 10, 20], [-0.5, -1.0], 10) - assert result == [10, 5, -5] + from linopy.piecewise import _slopes_to_points + + assert _slopes_to_points([0, 10, 20], [-0.5, -1.0], 10) == [10, 5, -5] def test_wrong_length_raises(self) -> None: + from linopy.piecewise import _slopes_to_points + with pytest.raises(ValueError, match="len\\(slopes\\)"): - slopes_to_points([0, 1, 2], [1], 0) + _slopes_to_points([0, 1, 2], [1], 0) # =========================================================================== @@ -96,61 +106,10 @@ def test_dict_without_dim_raises(self) -> None: with pytest.raises(ValueError, match="'dim' is required"): breakpoints({"a": [0, 50], "b": [0, 30]}) - def test_slopes_list(self) -> None: - bp = breakpoints(slopes=[1, 2], x_points=[0, 1, 2], y0=0) - expected = breakpoints([0, 1, 3]) - xr.testing.assert_equal(bp, expected) - - def test_slopes_dict(self) -> None: - bp = breakpoints( - slopes={"a": [1, 0.5], "b": [2, 1]}, - x_points={"a": [0, 10, 50], "b": [0, 20, 80]}, - y0={"a": 0, "b": 10}, - dim="gen", - ) - assert set(bp.dims) == {"gen", BREAKPOINT_DIM} - # a: [0, 10, 30], b: [10, 50, 110] - np.testing.assert_allclose(bp.sel(gen="a").values, [0, 10, 30]) - np.testing.assert_allclose(bp.sel(gen="b").values, [10, 50, 110]) - - def test_slopes_dict_shared_xpoints(self) -> None: - bp = breakpoints( - slopes={"a": [1, 2], "b": [3, 4]}, - x_points=[0, 1, 2], - y0={"a": 0, "b": 0}, - dim="gen", - ) - np.testing.assert_allclose(bp.sel(gen="a").values, [0, 1, 3]) - np.testing.assert_allclose(bp.sel(gen="b").values, [0, 3, 7]) - - def test_slopes_dict_shared_y0(self) -> None: - bp = breakpoints( - slopes={"a": [1, 2], "b": [3, 4]}, - x_points={"a": [0, 1, 2], "b": [0, 1, 2]}, - y0=5.0, - dim="gen", - ) - np.testing.assert_allclose(bp.sel(gen="a").values, [5, 6, 8]) - - def test_values_and_slopes_raises(self) -> None: - with pytest.raises(ValueError, match="mutually exclusive"): - breakpoints([0, 1], slopes=[1], x_points=[0, 1], y0=0) - - def test_slopes_without_xpoints_raises(self) -> None: - with pytest.raises(ValueError, match="requires both"): - breakpoints(slopes=[1], y0=0) - - def test_slopes_without_y0_raises(self) -> None: - with pytest.raises(ValueError, match="requires both"): - breakpoints(slopes=[1], x_points=[0, 1]) - - def test_xpoints_with_values_raises(self) -> None: - with pytest.raises(ValueError, match="forbidden"): - breakpoints([0, 1], x_points=[0, 1]) - - def test_y0_with_values_raises(self) -> None: - with pytest.raises(ValueError, match="forbidden"): - breakpoints([0, 1], y0=5) + def test_slopes_kwargs_removed(self) -> None: + """The slopes mode of ``breakpoints`` was removed in favour of ``Slopes``.""" + with pytest.raises(TypeError): + breakpoints([0, 1], slopes=[1], x_points=[0, 1], y0=0) # type: ignore[call-arg] # --- pandas and xarray inputs --- @@ -188,99 +147,6 @@ def test_dataarray_missing_dim_raises(self) -> None: with pytest.raises(ValueError, match="must have a"): breakpoints(da) - def test_slopes_series(self) -> None: - bp = breakpoints( - slopes=pd.Series([1, 2]), - x_points=pd.Series([0, 1, 2]), - y0=0, - ) - expected = breakpoints([0, 1, 3]) - xr.testing.assert_equal(bp, expected) - - def test_slopes_dataarray(self) -> None: - slopes_da = xr.DataArray( - [[1, 2], [3, 4]], - dims=["gen", BREAKPOINT_DIM], - coords={"gen": ["a", "b"], BREAKPOINT_DIM: [0, 1]}, - ) - xp_da = xr.DataArray( - [[0, 1, 2], [0, 1, 2]], - dims=["gen", BREAKPOINT_DIM], - coords={"gen": ["a", "b"], BREAKPOINT_DIM: [0, 1, 2]}, - ) - y0_da = xr.DataArray([0, 5], dims=["gen"], coords={"gen": ["a", "b"]}) - bp = breakpoints(slopes=slopes_da, x_points=xp_da, y0=y0_da, dim="gen") - np.testing.assert_allclose(bp.sel(gen="a").values, [0, 1, 3]) - np.testing.assert_allclose(bp.sel(gen="b").values, [5, 8, 12]) - - def test_slopes_dataframe(self) -> None: - slopes_df = pd.DataFrame({"a": [1, 0.5], "b": [2, 1]}).T - xp_df = pd.DataFrame({"a": [0, 10, 50], "b": [0, 20, 80]}).T - y0_series = pd.Series({"a": 0, "b": 10}) - bp = breakpoints(slopes=slopes_df, x_points=xp_df, y0=y0_series, dim="gen") - np.testing.assert_allclose(bp.sel(gen="a").values, [0, 10, 30]) - np.testing.assert_allclose(bp.sel(gen="b").values, [10, 50, 110]) - - -# =========================================================================== -# breakpoints(slopes_align="leading") -# =========================================================================== - - -class TestSlopesAlignLeading: - """ - `slopes_align="leading"` accepts slopes of length len(x_points), - where slopes[0] is a NaN sentinel that gets dropped. - """ - - def test_1d_matches_pieces(self) -> None: - leading = breakpoints( - slopes=[np.nan, 1, 2], x_points=[0, 1, 2], y0=0, slopes_align="leading" - ) - pieces = breakpoints(slopes=[1, 2], x_points=[0, 1, 2], y0=0) - xr.testing.assert_equal(leading, pieces) - - def test_dict_ragged(self) -> None: - bp = breakpoints( - slopes={"a": [np.nan, 1, 0.5], "b": [np.nan, 2]}, - x_points={"a": [0, 10, 50], "b": [0, 20]}, - y0={"a": 0, "b": 10}, - dim="gen", - slopes_align="leading", - ) - np.testing.assert_allclose(bp.sel(gen="a").values, [0, 10, 30]) - np.testing.assert_allclose( - bp.sel(gen="b").dropna(BREAKPOINT_DIM).values, [10, 50] - ) - - def test_dataarray(self) -> None: - slopes_da = xr.DataArray( - [[np.nan, 1, 2], [np.nan, 3, 4]], - dims=["gen", BREAKPOINT_DIM], - coords={"gen": ["a", "b"], BREAKPOINT_DIM: [0, 1, 2]}, - ) - xp_da = xr.DataArray( - [[0, 1, 2], [0, 1, 2]], - dims=["gen", BREAKPOINT_DIM], - coords={"gen": ["a", "b"], BREAKPOINT_DIM: [0, 1, 2]}, - ) - y0_da = xr.DataArray([0, 5], dims=["gen"], coords={"gen": ["a", "b"]}) - bp = breakpoints( - slopes=slopes_da, x_points=xp_da, y0=y0_da, slopes_align="leading" - ) - np.testing.assert_allclose(bp.sel(gen="a").values, [0, 1, 3]) - np.testing.assert_allclose(bp.sel(gen="b").values, [5, 8, 12]) - - def test_non_nan_first_slope_raises(self) -> None: - with pytest.raises(ValueError, match="first slope"): - breakpoints( - slopes=[1, 2, 3], x_points=[0, 1, 2], y0=0, slopes_align="leading" - ) - - def test_without_slopes_mode_raises(self) -> None: - with pytest.raises(ValueError, match="only valid in slopes mode"): - breakpoints([0, 1, 2], slopes_align="leading") - # =========================================================================== # Slopes class — deferred breakpoint spec @@ -331,6 +197,89 @@ def test_immutable(self) -> None: with pytest.raises((AttributeError, TypeError)): s.y0 = 5 # type: ignore[misc] + def test_to_breakpoints_series(self) -> None: + from linopy import Slopes + + bp = Slopes(pd.Series([1, 2]), y0=0).to_breakpoints(pd.Series([0, 1, 2])) + expected = breakpoints([0, 1, 3]) + xr.testing.assert_equal(bp, expected) + + def test_to_breakpoints_dataarray(self) -> None: + from linopy import Slopes + + slopes_da = xr.DataArray( + [[1, 2], [3, 4]], + dims=["gen", BREAKPOINT_DIM], + coords={"gen": ["a", "b"], BREAKPOINT_DIM: [0, 1]}, + ) + xp_da = xr.DataArray( + [[0, 1, 2], [0, 1, 2]], + dims=["gen", BREAKPOINT_DIM], + coords={"gen": ["a", "b"], BREAKPOINT_DIM: [0, 1, 2]}, + ) + y0_da = xr.DataArray([0, 5], dims=["gen"], coords={"gen": ["a", "b"]}) + bp = Slopes(slopes_da, y0=y0_da, dim="gen").to_breakpoints(xp_da) + np.testing.assert_allclose(bp.sel(gen="a").values, [0, 1, 3]) + np.testing.assert_allclose(bp.sel(gen="b").values, [5, 8, 12]) + + def test_to_breakpoints_dataframe(self) -> None: + from linopy import Slopes + + slopes_df = pd.DataFrame({"a": [1, 0.5], "b": [2, 1]}).T + xp_df = pd.DataFrame({"a": [0, 10, 50], "b": [0, 20, 80]}).T + y0_series = pd.Series({"a": 0, "b": 10}) + bp = Slopes(slopes_df, y0=y0_series, dim="gen").to_breakpoints(xp_df) + np.testing.assert_allclose(bp.sel(gen="a").values, [0, 10, 30]) + np.testing.assert_allclose(bp.sel(gen="b").values, [10, 50, 110]) + + def test_to_breakpoints_shared_x_grid(self) -> None: + """Per-entity slopes resolved against a single shared x grid.""" + from linopy import Slopes + + bp = Slopes({"a": [1, 2], "b": [3, 4]}, y0={"a": 0, "b": 0}, dim="gen") + result = bp.to_breakpoints([0, 1, 2]) + np.testing.assert_allclose(result.sel(gen="a").values, [0, 1, 3]) + np.testing.assert_allclose(result.sel(gen="b").values, [0, 3, 7]) + + def test_to_breakpoints_shared_y0(self) -> None: + """Scalar y0 broadcasts across entities.""" + from linopy import Slopes + + bp = Slopes({"a": [1, 2], "b": [3, 4]}, y0=5.0, dim="gen") + result = bp.to_breakpoints({"a": [0, 1, 2], "b": [0, 1, 2]}) + np.testing.assert_allclose(result.sel(gen="a").values, [5, 6, 8]) + + def test_to_breakpoints_align_leading_dict_ragged(self) -> None: + """Per-entity ``"leading"`` alignment with ragged (NaN-padded) input.""" + from linopy import Slopes + + bp = Slopes( + {"a": [np.nan, 1, 0.5], "b": [np.nan, 2]}, + y0={"a": 0, "b": 10}, + dim="gen", + align="leading", + ).to_breakpoints({"a": [0, 10, 50], "b": [0, 20]}) + np.testing.assert_allclose(bp.sel(gen="a").values, [0, 10, 30]) + np.testing.assert_allclose( + bp.sel(gen="b").dropna(BREAKPOINT_DIM).values, [10, 50] + ) + + def test_to_breakpoints_1d_non_scalar_y0_raises(self) -> None: + """1D slopes with dict y0 raises TypeError.""" + from linopy import Slopes + + with pytest.raises(TypeError, match="scalar float"): + Slopes([1, 2], y0={"a": 0}).to_breakpoints([0, 10, 20]) + + def test_to_breakpoints_bad_y0_type_raises(self) -> None: + """Multi-entity slopes with unsupported y0 type raises TypeError.""" + from linopy import Slopes + + with pytest.raises(TypeError, match="y0"): + Slopes({"a": [1, 2], "b": [3, 4]}, y0="bad", dim="gen").to_breakpoints( + {"a": [0, 10, 20], "b": [0, 10, 20]} + ) + class TestSlopesDispatch: """Slopes inside ``add_piecewise_formulation`` — sibling resolution.""" @@ -563,9 +512,11 @@ def test_with_slopes(self) -> None: y = m.add_variables(name="y") # slopes=[-0.3, 0.45, 1.2] with y0=5 -> y_points=[5, 2, 20, 80] # Non-monotonic y-breakpoints, so auto selects SOS2 + from linopy import Slopes + m.add_piecewise_formulation( (x, [0, 10, 50, 100]), - (y, breakpoints(slopes=[-0.3, 0.45, 1.2], x_points=[0, 10, 50, 100], y0=5)), + (y, Slopes([-0.3, 0.45, 1.2], y0=5)), ) assert f"pwl0{PWL_LAMBDA_SUFFIX}" in m.variables @@ -1184,10 +1135,12 @@ 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") + from linopy import Slopes + env2 = tangent_lines( x2, [0, 50, 100], - breakpoints(slopes=[0.8, 0.4], x_points=[0, 50, 100], y0=0), + Slopes([0.8, 0.4], y0=0).to_breakpoints([0, 50, 100]), ) m2.add_constraints(y2 <= env2, name="pwl") m2.add_constraints(x2 <= 75, name="x_max") @@ -1512,37 +1465,10 @@ def test_non_1d_sequence_raises(self) -> None: 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_breakpoints_no_values_raises(self) -> None: + """breakpoints() with no positional argument raises TypeError.""" + with pytest.raises(TypeError): + breakpoints() # type: ignore[call-arg] def test_non_numeric_breakpoint_coords_raises(self) -> None: """SOS2 with string breakpoint coords raises ValueError.""" From 09a7869d2969201be8d77b402efc219cda5ab657 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 May 2026 13:47:25 +0200 Subject: [PATCH 03/20] docs(piecewise): migrate slopes examples to Slopes class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ``doc/piecewise-linear-constraints.rst``: - Replace the ``breakpoints(slopes=, x_points=, y0=)`` quick-reference line with ``Slopes(values, y0=)`` (deferred form). - Rewrite the "From slopes" section to use ``Slopes`` inside ``add_piecewise_formulation``, plus a note on standalone resolution via ``Slopes.to_breakpoints(x_pts)``. * ``examples/piecewise-linear-constraints.ipynb``: add section 8 "Specifying with slopes — ``Slopes``" that reproduces the section-1 gas-turbine fit using slopes [1.2, 1.6, 2.15] over the same x grid, and demonstrates standalone ``Slopes.to_breakpoints(...)``. The inequality-bounds notebook doesn't reference the removed slopes APIs and stays focussed on curvature/LP dispatch — no changes there. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/piecewise-linear-constraints.rst | 20 +-- examples/piecewise-linear-constraints.ipynb | 144 +++++++++++++++++++- 2 files changed, 152 insertions(+), 12 deletions(-) diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 78f4ecd7..e364988c 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -96,7 +96,9 @@ Two factories with distinct geometric meaning: linopy.breakpoints([0, 50, 100]) # connected 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.Slopes( + [1.2, 1.4], y0=0 + ) # from slopes (deferred — pairs with a sibling tuple) linopy.segments([(0, 10), (50, 100)]) # two disjoint regions linopy.segments({"gen1": [(0, 10)], "gen2": [(0, 80)]}, dim="gen") @@ -240,21 +242,23 @@ Equivalent, but explicit about the DataArray construction: From slopes ~~~~~~~~~~~ -When you know marginal costs (slopes) rather than absolute values: +When you know marginal costs (slopes) rather than absolute values, wrap +them in :class:`linopy.Slopes`. The x grid is borrowed from the sibling +tuple — no need to repeat it: .. code-block:: python m.add_piecewise_formulation( (power, [0, 50, 100, 150]), - ( - cost, - linopy.breakpoints( - slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0 - ), - ), + (cost, linopy.Slopes([1.1, 1.5, 1.9], y0=0)), ) # cost breakpoints: [0, 55, 130, 225] +For standalone resolution outside of ``add_piecewise_formulation``, call +:meth:`linopy.Slopes.to_breakpoints` with an explicit x grid:: + + bp = linopy.Slopes([1.1, 1.5, 1.9], y0=0).to_breakpoints([0, 50, 100, 150]) + Per-entity breakpoints ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index a7011935..6786d2bc 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -25,6 +25,12 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:58.302751Z", "start_time": "2026-04-22T23:31:58.299283Z" + }, + "execution": { + "iopub.execute_input": "2026-05-06T11:46:26.933202Z", + "iopub.status.busy": "2026-05-06T11:46:26.933045Z", + "iopub.status.idle": "2026-05-06T11:46:27.664436Z", + "shell.execute_reply": "2026-05-06T11:46:27.664195Z" } }, "outputs": [], @@ -66,6 +72,12 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:58.464773Z", "start_time": "2026-04-22T23:31:58.310016Z" + }, + "execution": { + "iopub.execute_input": "2026-05-06T11:46:27.677845Z", + "iopub.status.busy": "2026-05-06T11:46:27.677693Z", + "iopub.status.idle": "2026-05-06T11:46:27.895933Z", + "shell.execute_reply": "2026-05-06T11:46:27.895759Z" } }, "outputs": [], @@ -94,6 +106,12 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:58.532078Z", "start_time": "2026-04-22T23:31:58.473509Z" + }, + "execution": { + "iopub.execute_input": "2026-05-06T11:46:27.896984Z", + "iopub.status.busy": "2026-05-06T11:46:27.896922Z", + "iopub.status.idle": "2026-05-06T11:46:28.003927Z", + "shell.execute_reply": "2026-05-06T11:46:28.003515Z" } }, "outputs": [], @@ -125,6 +143,12 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:58.952185Z", "start_time": "2026-04-22T23:31:58.537015Z" + }, + "execution": { + "iopub.execute_input": "2026-05-06T11:46:28.006335Z", + "iopub.status.busy": "2026-05-06T11:46:28.006175Z", + "iopub.status.idle": "2026-05-06T11:46:28.305396Z", + "shell.execute_reply": "2026-05-06T11:46:28.305212Z" } }, "outputs": [], @@ -160,6 +184,12 @@ "end_time": "2026-04-22T23:31:59.092539Z", "start_time": "2026-04-22T23:31:58.956054Z" }, + "execution": { + "iopub.execute_input": "2026-05-06T11:46:28.306523Z", + "iopub.status.busy": "2026-05-06T11:46:28.306468Z", + "iopub.status.idle": "2026-05-06T11:46:28.390302Z", + "shell.execute_reply": "2026-05-06T11:46:28.390127Z" + }, "scrolled": true }, "outputs": [], @@ -208,6 +238,12 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:59.210868Z", "start_time": "2026-04-22T23:31:59.098774Z" + }, + "execution": { + "iopub.execute_input": "2026-05-06T11:46:28.391336Z", + "iopub.status.busy": "2026-05-06T11:46:28.391282Z", + "iopub.status.idle": "2026-05-06T11:46:28.457648Z", + "shell.execute_reply": "2026-05-06T11:46:28.457441Z" } }, "outputs": [], @@ -234,7 +270,14 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-06T11:46:28.458704Z", + "iopub.status.busy": "2026-05-06T11:46:28.458589Z", + "iopub.status.idle": "2026-05-06T11:46:28.498684Z", + "shell.execute_reply": "2026-05-06T11:46:28.498484Z" + } + }, "outputs": [], "source": [ "# x_pts are fuel breakpoints, y_pts are power breakpoints — swap so power is on the x-axis\n", @@ -260,6 +303,12 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:59.422636Z", "start_time": "2026-04-22T23:31:59.232150Z" + }, + "execution": { + "iopub.execute_input": "2026-05-06T11:46:28.499749Z", + "iopub.status.busy": "2026-05-06T11:46:28.499681Z", + "iopub.status.idle": "2026-05-06T11:46:28.621239Z", + "shell.execute_reply": "2026-05-06T11:46:28.621054Z" } }, "outputs": [], @@ -289,7 +338,14 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-06T11:46:28.622302Z", + "iopub.status.busy": "2026-05-06T11:46:28.622248Z", + "iopub.status.idle": "2026-05-06T11:46:28.664874Z", + "shell.execute_reply": "2026-05-06T11:46:28.664672Z" + } + }, "outputs": [], "source": [ "plot_curve(x_pts, y_pts, m.solution[\"power\"].values, m.solution[\"fuel\"].values);" @@ -311,6 +367,12 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:59.598540Z", "start_time": "2026-04-22T23:31:59.433551Z" + }, + "execution": { + "iopub.execute_input": "2026-05-06T11:46:28.665944Z", + "iopub.status.busy": "2026-05-06T11:46:28.665883Z", + "iopub.status.idle": "2026-05-06T11:46:28.764287Z", + "shell.execute_reply": "2026-05-06T11:46:28.764122Z" } }, "outputs": [], @@ -337,7 +399,14 @@ { "cell_type": "code", "execution_count": null, - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-06T11:46:28.765294Z", + "iopub.status.busy": "2026-05-06T11:46:28.765239Z", + "iopub.status.idle": "2026-05-06T11:46:28.831955Z", + "shell.execute_reply": "2026-05-06T11:46:28.831719Z" + } + }, "outputs": [], "source": [ "fig, axes = plt.subplots(1, 2, figsize=(8, 3))\n", @@ -370,6 +439,12 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:59.801734Z", "start_time": "2026-04-22T23:31:59.606692Z" + }, + "execution": { + "iopub.execute_input": "2026-05-06T11:46:28.833242Z", + "iopub.status.busy": "2026-05-06T11:46:28.833177Z", + "iopub.status.idle": "2026-05-06T11:46:28.950062Z", + "shell.execute_reply": "2026-05-06T11:46:28.949876Z" } }, "outputs": [], @@ -391,6 +466,67 @@ "m.solve(reformulate_sos=\"auto\")\n", "m.solution[[\"power\", \"fuel\"]].to_dataframe()" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 8. Specifying with slopes — `Slopes`\n", + "\n", + "When you know marginal values (slopes) rather than absolute breakpoint y-values, wrap them in `linopy.Slopes`. The x grid is **borrowed from the sibling tuple** in the call — no need to repeat it. The example below reproduces the same gas-turbine fit as section 1, but specified via per-piece slopes plus an initial y-value:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-06T11:46:28.951259Z", + "iopub.status.busy": "2026-05-06T11:46:28.951200Z", + "iopub.status.idle": "2026-05-06T11:46:29.046463Z", + "shell.execute_reply": "2026-05-06T11:46:29.046289Z" + } + }, + "outputs": [], + "source": [ + "m = linopy.Model()\n", + "power = m.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", + "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", + "\n", + "# Same curve as section 1 — slopes 1.2, 1.6, 2.15 over x = [0, 30, 60, 100].\n", + "m.add_piecewise_formulation(\n", + " (power, [0, 30, 60, 100]),\n", + " (fuel, linopy.Slopes([1.2, 1.6, 2.15], y0=0)),\n", + ")\n", + "m.add_constraints(power == demand, name=\"demand\")\n", + "m.add_objective(fuel.sum())\n", + "m.solve(reformulate_sos=\"auto\")\n", + "\n", + "m.solution[[\"power\", \"fuel\"]].to_pandas()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For inspection or use outside `add_piecewise_formulation`, resolve a `Slopes` to a regular breakpoint `DataArray` by giving it an explicit x grid:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-06T11:46:29.047570Z", + "iopub.status.busy": "2026-05-06T11:46:29.047518Z", + "iopub.status.idle": "2026-05-06T11:46:29.052860Z", + "shell.execute_reply": "2026-05-06T11:46:29.052713Z" + } + }, + "outputs": [], + "source": [ + "linopy.Slopes([1.2, 1.6, 2.15], y0=0).to_breakpoints([0, 30, 60, 100])" + ] } ], "metadata": { @@ -409,7 +545,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.2" + "version": "3.11.11" } }, "nbformat": 4, From 59e3454f8bdc28a5216a4740fdd76c10752e9aea Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 May 2026 13:54:12 +0200 Subject: [PATCH 04/20] feat(piecewise): custom Slopes repr that hides defaults and summarises bulky values Default ``@dataclass`` repr was noisy: Slopes(values=[1.2, 1.6, 2.15], y0=0, align='pieces', dim=None) and would dump the full DataArray/DataFrame for non-list inputs. New repr: Slopes([1.2, 1.6, 2.15], y0=0) Slopes([nan, 1, 2], y0=0, align='leading') Slopes(, y0=0, dim='gen') Slopes(, y0=..., dim='gen') * The primary ``values`` arg renders without a keyword (positional like the constructor call) and inline only for plain lists/tuples; complex types (DataArray/DataFrame/Series/dict) get a one-line shape summary. * ``align`` and ``dim`` are omitted when at their defaults. * New ``_summarise_breakslike`` helper handles the value rendering. Notebook section 8 gains a "what does Slopes look like" peek cell that renders the repr before the in-formulation usage, so users see the value-type semantics directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/piecewise-linear-constraints.ipynb | 135 ++++++++++++-------- linopy/piecewise.py | 25 +++- test/test_piecewise_constraints.py | 35 +++++ 3 files changed, 138 insertions(+), 57 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 6786d2bc..4ece55f2 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -27,10 +27,10 @@ "start_time": "2026-04-22T23:31:58.299283Z" }, "execution": { - "iopub.execute_input": "2026-05-06T11:46:26.933202Z", - "iopub.status.busy": "2026-05-06T11:46:26.933045Z", - "iopub.status.idle": "2026-05-06T11:46:27.664436Z", - "shell.execute_reply": "2026-05-06T11:46:27.664195Z" + "iopub.execute_input": "2026-05-06T11:52:55.909739Z", + "iopub.status.busy": "2026-05-06T11:52:55.909506Z", + "iopub.status.idle": "2026-05-06T11:52:57.011003Z", + "shell.execute_reply": "2026-05-06T11:52:57.010754Z" } }, "outputs": [], @@ -74,10 +74,10 @@ "start_time": "2026-04-22T23:31:58.310016Z" }, "execution": { - "iopub.execute_input": "2026-05-06T11:46:27.677845Z", - "iopub.status.busy": "2026-05-06T11:46:27.677693Z", - "iopub.status.idle": "2026-05-06T11:46:27.895933Z", - "shell.execute_reply": "2026-05-06T11:46:27.895759Z" + "iopub.execute_input": "2026-05-06T11:52:57.012435Z", + "iopub.status.busy": "2026-05-06T11:52:57.012237Z", + "iopub.status.idle": "2026-05-06T11:52:57.370937Z", + "shell.execute_reply": "2026-05-06T11:52:57.370737Z" } }, "outputs": [], @@ -108,10 +108,10 @@ "start_time": "2026-04-22T23:31:58.473509Z" }, "execution": { - "iopub.execute_input": "2026-05-06T11:46:27.896984Z", - "iopub.status.busy": "2026-05-06T11:46:27.896922Z", - "iopub.status.idle": "2026-05-06T11:46:28.003927Z", - "shell.execute_reply": "2026-05-06T11:46:28.003515Z" + "iopub.execute_input": "2026-05-06T11:52:57.371950Z", + "iopub.status.busy": "2026-05-06T11:52:57.371864Z", + "iopub.status.idle": "2026-05-06T11:52:57.509811Z", + "shell.execute_reply": "2026-05-06T11:52:57.509425Z" } }, "outputs": [], @@ -145,10 +145,10 @@ "start_time": "2026-04-22T23:31:58.537015Z" }, "execution": { - "iopub.execute_input": "2026-05-06T11:46:28.006335Z", - "iopub.status.busy": "2026-05-06T11:46:28.006175Z", - "iopub.status.idle": "2026-05-06T11:46:28.305396Z", - "shell.execute_reply": "2026-05-06T11:46:28.305212Z" + "iopub.execute_input": "2026-05-06T11:52:57.512915Z", + "iopub.status.busy": "2026-05-06T11:52:57.512742Z", + "iopub.status.idle": "2026-05-06T11:52:57.807275Z", + "shell.execute_reply": "2026-05-06T11:52:57.807066Z" } }, "outputs": [], @@ -185,10 +185,10 @@ "start_time": "2026-04-22T23:31:58.956054Z" }, "execution": { - "iopub.execute_input": "2026-05-06T11:46:28.306523Z", - "iopub.status.busy": "2026-05-06T11:46:28.306468Z", - "iopub.status.idle": "2026-05-06T11:46:28.390302Z", - "shell.execute_reply": "2026-05-06T11:46:28.390127Z" + "iopub.execute_input": "2026-05-06T11:52:57.808386Z", + "iopub.status.busy": "2026-05-06T11:52:57.808329Z", + "iopub.status.idle": "2026-05-06T11:52:57.890471Z", + "shell.execute_reply": "2026-05-06T11:52:57.890272Z" }, "scrolled": true }, @@ -240,10 +240,10 @@ "start_time": "2026-04-22T23:31:59.098774Z" }, "execution": { - "iopub.execute_input": "2026-05-06T11:46:28.391336Z", - "iopub.status.busy": "2026-05-06T11:46:28.391282Z", - "iopub.status.idle": "2026-05-06T11:46:28.457648Z", - "shell.execute_reply": "2026-05-06T11:46:28.457441Z" + "iopub.execute_input": "2026-05-06T11:52:57.891766Z", + "iopub.status.busy": "2026-05-06T11:52:57.891691Z", + "iopub.status.idle": "2026-05-06T11:52:57.957556Z", + "shell.execute_reply": "2026-05-06T11:52:57.957357Z" } }, "outputs": [], @@ -272,10 +272,10 @@ "execution_count": null, "metadata": { "execution": { - "iopub.execute_input": "2026-05-06T11:46:28.458704Z", - "iopub.status.busy": "2026-05-06T11:46:28.458589Z", - "iopub.status.idle": "2026-05-06T11:46:28.498684Z", - "shell.execute_reply": "2026-05-06T11:46:28.498484Z" + "iopub.execute_input": "2026-05-06T11:52:57.958702Z", + "iopub.status.busy": "2026-05-06T11:52:57.958581Z", + "iopub.status.idle": "2026-05-06T11:52:57.995893Z", + "shell.execute_reply": "2026-05-06T11:52:57.995691Z" } }, "outputs": [], @@ -305,10 +305,10 @@ "start_time": "2026-04-22T23:31:59.232150Z" }, "execution": { - "iopub.execute_input": "2026-05-06T11:46:28.499749Z", - "iopub.status.busy": "2026-05-06T11:46:28.499681Z", - "iopub.status.idle": "2026-05-06T11:46:28.621239Z", - "shell.execute_reply": "2026-05-06T11:46:28.621054Z" + "iopub.execute_input": "2026-05-06T11:52:57.996958Z", + "iopub.status.busy": "2026-05-06T11:52:57.996888Z", + "iopub.status.idle": "2026-05-06T11:52:58.117758Z", + "shell.execute_reply": "2026-05-06T11:52:58.117569Z" } }, "outputs": [], @@ -340,10 +340,10 @@ "execution_count": null, "metadata": { "execution": { - "iopub.execute_input": "2026-05-06T11:46:28.622302Z", - "iopub.status.busy": "2026-05-06T11:46:28.622248Z", - "iopub.status.idle": "2026-05-06T11:46:28.664874Z", - "shell.execute_reply": "2026-05-06T11:46:28.664672Z" + "iopub.execute_input": "2026-05-06T11:52:58.118797Z", + "iopub.status.busy": "2026-05-06T11:52:58.118738Z", + "iopub.status.idle": "2026-05-06T11:52:58.161329Z", + "shell.execute_reply": "2026-05-06T11:52:58.161120Z" } }, "outputs": [], @@ -369,10 +369,10 @@ "start_time": "2026-04-22T23:31:59.433551Z" }, "execution": { - "iopub.execute_input": "2026-05-06T11:46:28.665944Z", - "iopub.status.busy": "2026-05-06T11:46:28.665883Z", - "iopub.status.idle": "2026-05-06T11:46:28.764287Z", - "shell.execute_reply": "2026-05-06T11:46:28.764122Z" + "iopub.execute_input": "2026-05-06T11:52:58.162560Z", + "iopub.status.busy": "2026-05-06T11:52:58.162485Z", + "iopub.status.idle": "2026-05-06T11:52:58.260466Z", + "shell.execute_reply": "2026-05-06T11:52:58.260287Z" } }, "outputs": [], @@ -401,10 +401,10 @@ "execution_count": null, "metadata": { "execution": { - "iopub.execute_input": "2026-05-06T11:46:28.765294Z", - "iopub.status.busy": "2026-05-06T11:46:28.765239Z", - "iopub.status.idle": "2026-05-06T11:46:28.831955Z", - "shell.execute_reply": "2026-05-06T11:46:28.831719Z" + "iopub.execute_input": "2026-05-06T11:52:58.261553Z", + "iopub.status.busy": "2026-05-06T11:52:58.261501Z", + "iopub.status.idle": "2026-05-06T11:52:58.330515Z", + "shell.execute_reply": "2026-05-06T11:52:58.330298Z" } }, "outputs": [], @@ -441,10 +441,10 @@ "start_time": "2026-04-22T23:31:59.606692Z" }, "execution": { - "iopub.execute_input": "2026-05-06T11:46:28.833242Z", - "iopub.status.busy": "2026-05-06T11:46:28.833177Z", - "iopub.status.idle": "2026-05-06T11:46:28.950062Z", - "shell.execute_reply": "2026-05-06T11:46:28.949876Z" + "iopub.execute_input": "2026-05-06T11:52:58.331628Z", + "iopub.status.busy": "2026-05-06T11:52:58.331565Z", + "iopub.status.idle": "2026-05-06T11:52:58.452296Z", + "shell.execute_reply": "2026-05-06T11:52:58.452098Z" } }, "outputs": [], @@ -476,15 +476,38 @@ "When you know marginal values (slopes) rather than absolute breakpoint y-values, wrap them in `linopy.Slopes`. The x grid is **borrowed from the sibling tuple** in the call — no need to repeat it. The example below reproduces the same gas-turbine fit as section 1, but specified via per-piece slopes plus an initial y-value:" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A `Slopes(...)` instance carries the spec; non-default ``align`` and ``dim`` are shown in its repr, defaults are hidden:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-06T11:52:58.453727Z", + "iopub.status.busy": "2026-05-06T11:52:58.453664Z", + "iopub.status.idle": "2026-05-06T11:52:58.455340Z", + "shell.execute_reply": "2026-05-06T11:52:58.455181Z" + } + }, + "outputs": [], + "source": [ + "linopy.Slopes([1.2, 1.6, 2.15], y0=0)" + ] + }, { "cell_type": "code", "execution_count": null, "metadata": { "execution": { - "iopub.execute_input": "2026-05-06T11:46:28.951259Z", - "iopub.status.busy": "2026-05-06T11:46:28.951200Z", - "iopub.status.idle": "2026-05-06T11:46:29.046463Z", - "shell.execute_reply": "2026-05-06T11:46:29.046289Z" + "iopub.execute_input": "2026-05-06T11:52:58.456960Z", + "iopub.status.busy": "2026-05-06T11:52:58.456892Z", + "iopub.status.idle": "2026-05-06T11:52:58.549899Z", + "shell.execute_reply": "2026-05-06T11:52:58.549720Z" } }, "outputs": [], @@ -517,10 +540,10 @@ "execution_count": null, "metadata": { "execution": { - "iopub.execute_input": "2026-05-06T11:46:29.047570Z", - "iopub.status.busy": "2026-05-06T11:46:29.047518Z", - "iopub.status.idle": "2026-05-06T11:46:29.052860Z", - "shell.execute_reply": "2026-05-06T11:46:29.052713Z" + "iopub.execute_input": "2026-05-06T11:52:58.551007Z", + "iopub.status.busy": "2026-05-06T11:52:58.550949Z", + "iopub.status.idle": "2026-05-06T11:52:58.556338Z", + "shell.execute_reply": "2026-05-06T11:52:58.556178Z" } }, "outputs": [], diff --git a/linopy/piecewise.py b/linopy/piecewise.py index c649eb41..49155297 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -94,7 +94,7 @@ def _warn_evolving_api(key: _EvolvingApiKey, message: str) -> None: # --------------------------------------------------------------------------- -@dataclass(frozen=True, slots=True) +@dataclass(frozen=True, slots=True, repr=False) class Slopes: """ Per-piece slopes + initial y-value, deferred until an x grid is known. @@ -161,6 +161,29 @@ def to_breakpoints(self, x_points: BreaksLike) -> DataArray: self.values, x_points, self.y0, self.dim, self.align ) + def __repr__(self) -> str: + bits = [_summarise_breakslike(self.values), f"y0={self.y0!r}"] + if self.align != "pieces": + bits.append(f"align={self.align!r}") + if self.dim is not None: + bits.append(f"dim={self.dim!r}") + return f"Slopes({', '.join(bits)})" + + +def _summarise_breakslike(v: BreaksLike) -> str: + """Compact one-line summary of a BreaksLike value for use in reprs.""" + if isinstance(v, DataArray): + sizes = ", ".join(f"{d}: {s}" for d, s in v.sizes.items()) + return f"" + if isinstance(v, pd.DataFrame): + return f"" + if isinstance(v, pd.Series): + return f"" + if isinstance(v, dict): + return f"" + # Sequence[float] (list, tuple, ndarray of small size) — render inline. + return repr(v) + # Tuple element type covering both eager (DataArray etc.) and deferred (Slopes) bps. BreaksOrSlopes: TypeAlias = BreaksLike | Slopes diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 5b371c64..f8d4e003 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -197,6 +197,41 @@ def test_immutable(self) -> None: with pytest.raises((AttributeError, TypeError)): s.y0 = 5 # type: ignore[misc] + def test_repr_hides_defaults_for_1d_list(self) -> None: + from linopy import Slopes + + # Defaults (align="pieces", dim=None) must be omitted; the values + # list renders inline so users can read it. + assert repr(Slopes([1.2, 1.6, 2.15], y0=0)) == "Slopes([1.2, 1.6, 2.15], y0=0)" + + def test_repr_shows_non_default_align(self) -> None: + from linopy import Slopes + + r = repr(Slopes([np.nan, 1, 2], y0=0, align="leading")) + assert "align='leading'" in r + assert "dim=" not in r + + def test_repr_summarises_dataframe(self) -> None: + from linopy import Slopes + + df = pd.DataFrame({"a": [1, 2], "b": [3, 4]}).T + r = repr(Slopes(df, y0=0, dim="gen")) + # Must not dump the full DataFrame; should summarise as . + assert " None: + from linopy import Slopes + + da = xr.DataArray( + [[1, 2], [3, 4]], + dims=["gen", BREAKPOINT_DIM], + coords={"gen": ["a", "b"]}, + ) + r = repr(Slopes(da, y0=0, dim="gen")) + assert " None: from linopy import Slopes From 7da32c1396b62a9a086e77b81f1ae0197416c648 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 May 2026 13:59:04 +0200 Subject: [PATCH 05/20] test(piecewise): consolidate Slopes tests into focused, parametrised classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flat list of ``test_to_breakpoints_*`` methods had drifted into one case per (input shape × input type) combination — duplicated bodies, hard to scan, easy to miss a type. Restructure into five classes, each pinning one aspect of the contract: * ``TestSlopesValueType`` — immutability + repr. Repr behaviour parametrised over (1d-defaults-hidden, non-default-align, non-default-dim) for the format check, and over (DataFrame, DataArray, Series, dict) for the bulky-value summary. * ``TestSlopesToBreakpoints1D`` — same arithmetic anchor (slopes [1, 2] over x [0, 1, 2] → y [0, 1, 3]) under every accepted 1D input type pairing (list, tuple, ndarray, Series, DataArray, mixed). Plus a separate parametrised "arithmetic anchors" set covering negative slopes, non-zero y0, and uneven x spacing. * ``TestSlopesToBreakpointsPerEntity`` — same per-entity anchor (gen=a → [0, 10, 30]; gen=b → [10, 50, 110]) under every accepted multi-entity container type (dict, DataFrame, DataArray). Plus shared-x-grid broadcast and ``y0`` shape coverage (scalar, dict, Series, DataArray) under one parametrised test. * ``TestSlopesToBreakpointsAlignment`` — ``align="pieces"`` and ``align="leading"`` must produce equal output for matching inputs; parametrised over 1D and per-entity-dict shapes. Ragged per-entity case kept as a dedicated test. * ``TestSlopesValidationErrors`` — three rejection paths (leading-first-not-NaN, 1D + dict y0, bad y0 type) parametrised in one test. Net: 17 individual tests collapse into 32 parametrised cases under 5 classes, with each behaviour-of-interest in exactly one place. Also adds the missing ``BreaksLike`` import in the test-only ``TYPE_CHECKING`` block (used in the new parametrised signatures). Co-Authored-By: Claude Opus 4.7 (1M context) --- test/test_piecewise_constraints.py | 365 ++++++++++++++++++++--------- 1 file changed, 258 insertions(+), 107 deletions(-) diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index f8d4e003..3ed19414 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -43,7 +43,7 @@ from linopy.solver_capabilities import SolverFeature, get_available_solvers_with_feature if TYPE_CHECKING: - from linopy.piecewise import _PwlInputs + from linopy.piecewise import BreaksLike, _PwlInputs Sign: TypeAlias = Literal["==", "<=", ">="] Method: TypeAlias = Literal["sos2", "incremental", "lp", "auto"] @@ -153,139 +153,268 @@ def test_dataarray_missing_dim_raises(self) -> None: # =========================================================================== -class TestSlopesClass: - """Direct unit tests for the ``Slopes`` value type.""" +class TestSlopesValueType: + """``Slopes`` is a frozen value type with a custom repr.""" - def test_to_breakpoints_1d(self) -> None: + def test_immutable(self) -> None: from linopy import Slopes - s = Slopes([1.2, 1.4, 1.7], y0=0) - bp = s.to_breakpoints([0, 30, 60, 100]) - # y0=0; piece increments: 30*1.2=36, 30*1.4=42, 40*1.7=68 - # cumulative: 0, 36, 78, 146 - assert list(bp.values) == pytest.approx([0, 36, 78, 146]) + s = Slopes([1, 2], y0=0) + with pytest.raises((AttributeError, TypeError)): + s.y0 = 5 # type: ignore[misc] - def test_to_breakpoints_per_entity_dict(self) -> None: + @pytest.mark.parametrize( + ("kwargs", "expected"), + [ + pytest.param( + {"values": [1.2, 1.6, 2.15], "y0": 0}, + "Slopes([1.2, 1.6, 2.15], y0=0)", + id="1d_list_defaults_hidden", + ), + pytest.param( + {"values": [np.nan, 1, 2], "y0": 0, "align": "leading"}, + "align='leading'", + id="non_default_align_shown", + ), + pytest.param( + {"values": [1, 2], "y0": 0, "dim": "gen"}, + "dim='gen'", + id="non_default_dim_shown", + ), + ], + ) + def test_repr_renders(self, kwargs: dict[str, Any], expected: str) -> None: from linopy import Slopes - s = Slopes({"a": [1, 0.5], "b": [2, 1]}, y0={"a": 0, "b": 10}, dim="gen") - bp = s.to_breakpoints({"a": [0, 10, 20], "b": [0, 5, 10]}) - # a: y0=0, +10*1=10, +10*0.5=15 -> [0, 10, 15] - # b: y0=10, +5*2=20, +5*1=25 -> [10, 20, 25] - assert "gen" in bp.dims - assert bp.sel(gen="a").values.tolist() == pytest.approx([0, 10, 15]) - assert bp.sel(gen="b").values.tolist() == pytest.approx([10, 20, 25]) + r = repr(Slopes(**kwargs)) + if expected.startswith("Slopes("): + assert r == expected + else: + assert expected in r - def test_to_breakpoints_align_leading(self) -> None: + @pytest.mark.parametrize( + ("values", "fragment"), + [ + pytest.param( + pd.DataFrame({"a": [1, 2], "b": [3, 4]}).T, + "", + id="dict", + ), + ], + ) + def test_repr_summarises_bulky_values( + self, values: BreaksLike, fragment: str + ) -> None: + """Bulky value types must not dump their full content into the repr.""" from linopy import Slopes - # Same as align="pieces" with [1, 2], plus a leading NaN to drop. - s = Slopes([np.nan, 1, 2], y0=0, align="leading") - bp = s.to_breakpoints([0, 1, 2]) - assert list(bp.values) == pytest.approx([0, 1, 3]) - - def test_align_leading_first_not_nan_raises(self) -> None: - from linopy import Slopes + r = repr(Slopes(values, y0=0, dim="gen")) + assert fragment in r - with pytest.raises(ValueError, match="first slope"): - Slopes([1, 2, 3], y0=0, align="leading").to_breakpoints([0, 1, 2]) - def test_immutable(self) -> None: - from linopy import Slopes +class TestSlopesToBreakpoints1D: + """ + 1D inputs (single shared curve). All callable input types must + resolve to the same DataArray for the same data: slopes [1, 2] over + x = [0, 1, 2] with y0=0 yields y = [0, 1, 3]. + """ - s = Slopes([1, 2], y0=0) - with pytest.raises((AttributeError, TypeError)): - s.y0 = 5 # type: ignore[misc] + EXPECTED = [0.0, 1.0, 3.0] - def test_repr_hides_defaults_for_1d_list(self) -> None: + @pytest.mark.parametrize( + ("slopes_in", "x_in"), + [ + pytest.param([1, 2], [0, 1, 2], id="list-list"), + pytest.param((1, 2), (0, 1, 2), id="tuple-tuple"), + pytest.param(np.array([1, 2]), np.array([0, 1, 2]), id="ndarray-ndarray"), + pytest.param(pd.Series([1, 2]), pd.Series([0, 1, 2]), id="series-series"), + pytest.param([1, 2], np.array([0, 1, 2]), id="list-ndarray-mixed"), + pytest.param( + xr.DataArray([1, 2], dims=[BREAKPOINT_DIM]), + xr.DataArray([0, 1, 2], dims=[BREAKPOINT_DIM]), + id="dataarray-dataarray", + ), + ], + ) + def test_resolves_to_expected_breakpoints( + self, slopes_in: BreaksLike, x_in: BreaksLike + ) -> None: from linopy import Slopes - # Defaults (align="pieces", dim=None) must be omitted; the values - # list renders inline so users can read it. - assert repr(Slopes([1.2, 1.6, 2.15], y0=0)) == "Slopes([1.2, 1.6, 2.15], y0=0)" + bp = Slopes(slopes_in, y0=0).to_breakpoints(x_in) + assert bp.dims == (BREAKPOINT_DIM,) + np.testing.assert_allclose(bp.values, self.EXPECTED) - def test_repr_shows_non_default_align(self) -> None: + @pytest.mark.parametrize( + ("slopes", "x_pts", "y0", "expected"), + [ + pytest.param([1, 2], [0, 1, 2], 0, [0, 1, 3], id="canonical"), + pytest.param( + [1.2, 1.4, 1.7], + [0, 30, 60, 100], + 0, + [0, 36, 78, 146], + id="non_unit_slopes", + ), + pytest.param([-0.5, -1.0], [0, 10, 20], 10, [10, 5, -5], id="negative"), + pytest.param([1, 2], [0, 1, 2], 5, [5, 6, 8], id="non_zero_y0"), + ], + ) + def test_arithmetic_anchors( + self, + slopes: list[float], + x_pts: list[float], + y0: float, + expected: list[float], + ) -> None: + """Hand-computable cases pinning the slopes→y arithmetic.""" from linopy import Slopes - r = repr(Slopes([np.nan, 1, 2], y0=0, align="leading")) - assert "align='leading'" in r - assert "dim=" not in r + bp = Slopes(slopes, y0=y0).to_breakpoints(x_pts) + np.testing.assert_allclose(bp.values, expected) - def test_repr_summarises_dataframe(self) -> None: - from linopy import Slopes - df = pd.DataFrame({"a": [1, 2], "b": [3, 4]}).T - r = repr(Slopes(df, y0=0, dim="gen")) - # Must not dump the full DataFrame; should summarise as . - assert " None: - from linopy import Slopes + Reference data: gen=a slopes [1, 0.5] over x=[0, 10, 50] from y0=0 + → [0, 10, 30]; gen=b slopes [2, 1] over x=[0, 20, 80] from y0=10 + → [10, 50, 110]. + """ - da = xr.DataArray( - [[1, 2], [3, 4]], - dims=["gen", BREAKPOINT_DIM], - coords={"gen": ["a", "b"]}, - ) - r = repr(Slopes(da, y0=0, dim="gen")) - assert " None: + @pytest.mark.parametrize( + ("slopes_in", "x_in"), + [ + pytest.param( + {"a": [1, 0.5], "b": [2, 1]}, + {"a": [0, 10, 50], "b": [0, 20, 80]}, + id="dict-dict", + ), + pytest.param( + pd.DataFrame({"a": [1, 0.5], "b": [2, 1]}).T, + pd.DataFrame({"a": [0, 10, 50], "b": [0, 20, 80]}).T, + id="dataframe-dataframe", + ), + pytest.param( + xr.DataArray( + [[1, 0.5], [2, 1]], + dims=["gen", BREAKPOINT_DIM], + coords={"gen": ["a", "b"], BREAKPOINT_DIM: [0, 1]}, + ), + xr.DataArray( + [[0, 10, 50], [0, 20, 80]], + dims=["gen", BREAKPOINT_DIM], + coords={"gen": ["a", "b"], BREAKPOINT_DIM: [0, 1, 2]}, + ), + id="dataarray-dataarray", + ), + ], + ) + def test_resolves_to_expected_per_entity( + self, slopes_in: BreaksLike, x_in: BreaksLike + ) -> None: from linopy import Slopes - bp = Slopes(pd.Series([1, 2]), y0=0).to_breakpoints(pd.Series([0, 1, 2])) - expected = breakpoints([0, 1, 3]) - xr.testing.assert_equal(bp, expected) + bp = Slopes(slopes_in, y0={"a": 0, "b": 10}, dim="gen").to_breakpoints(x_in) + assert "gen" in bp.dims and BREAKPOINT_DIM in bp.dims + np.testing.assert_allclose(bp.sel(gen="a").values, self.EXPECTED_A) + np.testing.assert_allclose(bp.sel(gen="b").values, self.EXPECTED_B) - def test_to_breakpoints_dataarray(self) -> None: + def test_shared_x_grid_broadcasts(self) -> None: + """Per-entity slopes against a single shared x grid (1D x_points).""" from linopy import Slopes - slopes_da = xr.DataArray( - [[1, 2], [3, 4]], - dims=["gen", BREAKPOINT_DIM], - coords={"gen": ["a", "b"], BREAKPOINT_DIM: [0, 1]}, - ) - xp_da = xr.DataArray( - [[0, 1, 2], [0, 1, 2]], - dims=["gen", BREAKPOINT_DIM], - coords={"gen": ["a", "b"], BREAKPOINT_DIM: [0, 1, 2]}, - ) - y0_da = xr.DataArray([0, 5], dims=["gen"], coords={"gen": ["a", "b"]}) - bp = Slopes(slopes_da, y0=y0_da, dim="gen").to_breakpoints(xp_da) + bp = Slopes( + {"a": [1, 2], "b": [3, 4]}, y0={"a": 0, "b": 0}, dim="gen" + ).to_breakpoints([0, 1, 2]) np.testing.assert_allclose(bp.sel(gen="a").values, [0, 1, 3]) - np.testing.assert_allclose(bp.sel(gen="b").values, [5, 8, 12]) + np.testing.assert_allclose(bp.sel(gen="b").values, [0, 3, 7]) - def test_to_breakpoints_dataframe(self) -> None: + @pytest.mark.parametrize( + ("y0", "id"), + [ + pytest.param(5.0, "scalar"), + pytest.param({"a": 5, "b": 5}, "dict"), + pytest.param(pd.Series({"a": 5, "b": 5}), "series"), + pytest.param( + xr.DataArray([5, 5], dims=["gen"], coords={"gen": ["a", "b"]}), + "dataarray", + ), + ], + ids=lambda x: x if isinstance(x, str) else None, + ) + def test_y0_input_types_broadcast_consistently(self, y0: object, id: str) -> None: + """All accepted ``y0`` shapes resolve to the same per-entity result.""" from linopy import Slopes - slopes_df = pd.DataFrame({"a": [1, 0.5], "b": [2, 1]}).T - xp_df = pd.DataFrame({"a": [0, 10, 50], "b": [0, 20, 80]}).T - y0_series = pd.Series({"a": 0, "b": 10}) - bp = Slopes(slopes_df, y0=y0_series, dim="gen").to_breakpoints(xp_df) - np.testing.assert_allclose(bp.sel(gen="a").values, [0, 10, 30]) - np.testing.assert_allclose(bp.sel(gen="b").values, [10, 50, 110]) + bp = Slopes({"a": [1, 2], "b": [3, 4]}, y0=y0, dim="gen").to_breakpoints( + {"a": [0, 1, 2], "b": [0, 1, 2]} + ) + np.testing.assert_allclose(bp.sel(gen="a").values, [5, 6, 8]) + np.testing.assert_allclose(bp.sel(gen="b").values, [5, 8, 12]) - def test_to_breakpoints_shared_x_grid(self) -> None: - """Per-entity slopes resolved against a single shared x grid.""" - from linopy import Slopes - bp = Slopes({"a": [1, 2], "b": [3, 4]}, y0={"a": 0, "b": 0}, dim="gen") - result = bp.to_breakpoints([0, 1, 2]) - np.testing.assert_allclose(result.sel(gen="a").values, [0, 1, 3]) - np.testing.assert_allclose(result.sel(gen="b").values, [0, 3, 7]) +class TestSlopesToBreakpointsAlignment: + """ + ``align="pieces"`` (n-1 slopes) and ``align="leading"`` (n slopes + with a NaN sentinel in position 0) describe the same curve. They + must produce the same breakpoint DataArray. + """ - def test_to_breakpoints_shared_y0(self) -> None: - """Scalar y0 broadcasts across entities.""" + @pytest.mark.parametrize( + ("pieces_input", "leading_input"), + [ + pytest.param([1, 2], [np.nan, 1, 2], id="1d"), + pytest.param( + {"a": [1, 0.5], "b": [2, 1]}, + {"a": [np.nan, 1, 0.5], "b": [np.nan, 2, 1]}, + id="dict_per_entity", + ), + ], + ) + def test_pieces_and_leading_match( + self, pieces_input: BreaksLike, leading_input: BreaksLike + ) -> None: from linopy import Slopes - bp = Slopes({"a": [1, 2], "b": [3, 4]}, y0=5.0, dim="gen") - result = bp.to_breakpoints({"a": [0, 1, 2], "b": [0, 1, 2]}) - np.testing.assert_allclose(result.sel(gen="a").values, [5, 6, 8]) + kwargs: dict[str, Any] = {"y0": 0} + if isinstance(pieces_input, dict): + kwargs.update(dim="gen", y0={"a": 0, "b": 10}) + x_pts: BreaksLike = {"a": [0, 10, 50], "b": [0, 20, 80]} + else: + x_pts = [0, 1, 2] + pieces_bp = Slopes(pieces_input, align="pieces", **kwargs).to_breakpoints(x_pts) + leading_bp = Slopes(leading_input, align="leading", **kwargs).to_breakpoints( + x_pts + ) + xr.testing.assert_allclose(pieces_bp, leading_bp) - def test_to_breakpoints_align_leading_dict_ragged(self) -> None: - """Per-entity ``"leading"`` alignment with ragged (NaN-padded) input.""" + def test_leading_ragged_dict(self) -> None: + """``align='leading'`` with ragged per-entity input keeps NaN padding.""" from linopy import Slopes bp = Slopes( @@ -299,21 +428,43 @@ def test_to_breakpoints_align_leading_dict_ragged(self) -> None: bp.sel(gen="b").dropna(BREAKPOINT_DIM).values, [10, 50] ) - def test_to_breakpoints_1d_non_scalar_y0_raises(self) -> None: - """1D slopes with dict y0 raises TypeError.""" - from linopy import Slopes - with pytest.raises(TypeError, match="scalar float"): - Slopes([1, 2], y0={"a": 0}).to_breakpoints([0, 10, 20]) +class TestSlopesValidationErrors: + """``to_breakpoints`` rejects malformed specs with actionable messages.""" - def test_to_breakpoints_bad_y0_type_raises(self) -> None: - """Multi-entity slopes with unsupported y0 type raises TypeError.""" + @pytest.mark.parametrize( + ("ctor_kwargs", "x_pts", "match"), + [ + pytest.param( + {"values": [1, 2, 3], "y0": 0, "align": "leading"}, + [0, 1, 2], + "first slope", + id="leading_first_not_nan", + ), + pytest.param( + {"values": [1, 2], "y0": {"a": 0}}, + [0, 10, 20], + "scalar float", + id="1d_with_dict_y0", + ), + pytest.param( + {"values": {"a": [1, 2], "b": [3, 4]}, "y0": "bad", "dim": "gen"}, + {"a": [0, 10, 20], "b": [0, 10, 20]}, + "y0", + id="bad_y0_type", + ), + ], + ) + def test_invalid_inputs_raise( + self, + ctor_kwargs: dict[str, Any], + x_pts: BreaksLike, + match: str, + ) -> None: from linopy import Slopes - with pytest.raises(TypeError, match="y0"): - Slopes({"a": [1, 2], "b": [3, 4]}, y0="bad", dim="gen").to_breakpoints( - {"a": [0, 10, 20], "b": [0, 10, 20]} - ) + with pytest.raises((TypeError, ValueError), match=match): + Slopes(**ctor_kwargs).to_breakpoints(x_pts) class TestSlopesDispatch: From fd62ba83b959ae718f9f9a11ffc2393f41ca7a35 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 May 2026 14:02:36 +0200 Subject: [PATCH 06/20] chore: hoist _slopes_to_points test import + strip notebook execution metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ``test/test_piecewise_constraints.py``: hoist the ``from linopy.piecewise import _slopes_to_points`` to module scope — was repeated inside each of the three ``TestSlopesToPointsPrivate`` methods. * ``examples/piecewise-linear-constraints.ipynb``: strip ``cell.metadata.execution`` (iopub timestamps) from all cells. The ``jupyter-notebook-cleanup`` pre-commit hook clears outputs but doesn't touch this field, so it accumulated noise in the diff every time the notebook was re-executed. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/piecewise-linear-constraints.ipynb | 140 +++----------------- test/test_piecewise_constraints.py | 7 +- 2 files changed, 23 insertions(+), 124 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 4ece55f2..21d5b39f 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -6,7 +6,7 @@ "source": [ "# Piecewise Linear Constraints Tutorial\n", "\n", - "`add_piecewise_formulation` links variables through shared breakpoint weights. Every section below stacks one feature on top of a small shared dispatch pattern — if you want the math, see the [reference page](piecewise-linear-constraints). For inequality bounds and the LP chord formulation in depth, see the [inequality bounds notebook](piecewise-inequality-bounds-tutorial).\n", + "`add_piecewise_formulation` links variables through shared breakpoint weights. Every section below stacks one feature on top of a small shared dispatch pattern \u2014 if you want the math, see the [reference page](piecewise-linear-constraints). For inequality bounds and the LP chord formulation in depth, see the [inequality bounds notebook](piecewise-inequality-bounds-tutorial).\n", "\n", "The baseline we extend:\n", "\n", @@ -25,12 +25,6 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:58.302751Z", "start_time": "2026-04-22T23:31:58.299283Z" - }, - "execution": { - "iopub.execute_input": "2026-05-06T11:52:55.909739Z", - "iopub.status.busy": "2026-05-06T11:52:55.909506Z", - "iopub.status.idle": "2026-05-06T11:52:57.011003Z", - "shell.execute_reply": "2026-05-06T11:52:57.010754Z" } }, "outputs": [], @@ -72,12 +66,6 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:58.464773Z", "start_time": "2026-04-22T23:31:58.310016Z" - }, - "execution": { - "iopub.execute_input": "2026-05-06T11:52:57.012435Z", - "iopub.status.busy": "2026-05-06T11:52:57.012237Z", - "iopub.status.idle": "2026-05-06T11:52:57.370937Z", - "shell.execute_reply": "2026-05-06T11:52:57.370737Z" } }, "outputs": [], @@ -106,12 +94,6 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:58.532078Z", "start_time": "2026-04-22T23:31:58.473509Z" - }, - "execution": { - "iopub.execute_input": "2026-05-06T11:52:57.371950Z", - "iopub.status.busy": "2026-05-06T11:52:57.371864Z", - "iopub.status.idle": "2026-05-06T11:52:57.509811Z", - "shell.execute_reply": "2026-05-06T11:52:57.509425Z" } }, "outputs": [], @@ -125,13 +107,13 @@ "source": [ "## 2. Picking a method\n", "\n", - "`method=\"auto\"` (default) picks the cheapest correct formulation based on sign, curvature and breakpoint layout. The explicit options — `\"sos2\"`, `\"incremental\"`, `\"lp\"` — give the same optimum on equality cases where they all apply, so the choice is about **cost** (auxiliary variables, solver capability), not correctness.\n", + "`method=\"auto\"` (default) picks the cheapest correct formulation based on sign, curvature and breakpoint layout. The explicit options \u2014 `\"sos2\"`, `\"incremental\"`, `\"lp\"` \u2014 give the same optimum on equality cases where they all apply, so the choice is about **cost** (auxiliary variables, solver capability), not correctness.\n", "\n", "| method | needs | creates |\n", "|---|---|---|\n", "| `sos2` | SOS2-capable solver | lambdas (continuous) |\n", "| `incremental` | MIP solver, strictly monotonic breakpoints | deltas (continuous) + binaries |\n", - "| `lp` | any LP solver | no variables — requires `sign != \"==\"`, 2 tuples, matching curvature |\n", + "| `lp` | any LP solver | no variables \u2014 requires `sign != \"==\"`, 2 tuples, matching curvature |\n", "\n", "Below: all applicable methods yield the same fuel dispatch on this convex curve." ] @@ -143,12 +125,6 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:58.952185Z", "start_time": "2026-04-22T23:31:58.537015Z" - }, - "execution": { - "iopub.execute_input": "2026-05-06T11:52:57.512915Z", - "iopub.status.busy": "2026-05-06T11:52:57.512742Z", - "iopub.status.idle": "2026-05-06T11:52:57.807275Z", - "shell.execute_reply": "2026-05-06T11:52:57.807066Z" } }, "outputs": [], @@ -171,7 +147,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 3. Disjunctive segments — gaps in the operating range\n", + "## 3. Disjunctive segments \u2014 gaps in the operating range\n", "\n", "When operating regions are **disconnected** (a diesel generator that is either off or running in [50, 80] MW, never in between), use `segments()` instead of `breakpoints()`. A binary picks which segment is active; inside it SOS2 interpolates as usual." ] @@ -184,12 +160,6 @@ "end_time": "2026-04-22T23:31:59.092539Z", "start_time": "2026-04-22T23:31:58.956054Z" }, - "execution": { - "iopub.execute_input": "2026-05-06T11:52:57.808386Z", - "iopub.status.busy": "2026-05-06T11:52:57.808329Z", - "iopub.status.idle": "2026-05-06T11:52:57.890471Z", - "shell.execute_reply": "2026-05-06T11:52:57.890272Z" - }, "scrolled": true }, "outputs": [], @@ -220,11 +190,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 4. Inequality bounds — per-tuple sign\n", + "## 4. Inequality bounds \u2014 per-tuple sign\n", "\n", - "Append a third tuple element (`\"<=\"` or `\">=\"`) to mark a single expression as **bounded** by the piecewise curve instead of pinned to it. The other tuples stay on the curve. The 2-variable hypograph (`y ≤ f(x)`) and epigraph (`y ≥ f(x)`) are the canonical cases.\n", + "Append a third tuple element (`\"<=\"` or `\">=\"`) to mark a single expression as **bounded** by the piecewise curve instead of pinned to it. The other tuples stay on the curve. The 2-variable hypograph (`y \u2264 f(x)`) and epigraph (`y \u2265 f(x)`) are the canonical cases.\n", "\n", - "On a concave curve with `<=` (or convex with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation — no binaries, no SOS2.\n", + "On a concave curve with `<=` (or convex with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation \u2014 no binaries, no SOS2.\n", "\n", "At most one tuple may carry a non-equality sign. With 3 or more tuples, all signs must be `\"==\"`; the multi-input bounded case is reserved for a future bivariate / triangulated piecewise API.\n", "\n", @@ -238,12 +208,6 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:59.210868Z", "start_time": "2026-04-22T23:31:59.098774Z" - }, - "execution": { - "iopub.execute_input": "2026-05-06T11:52:57.891766Z", - "iopub.status.busy": "2026-05-06T11:52:57.891691Z", - "iopub.status.idle": "2026-05-06T11:52:57.957556Z", - "shell.execute_reply": "2026-05-06T11:52:57.957357Z" } }, "outputs": [], @@ -270,17 +234,10 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-06T11:52:57.958702Z", - "iopub.status.busy": "2026-05-06T11:52:57.958581Z", - "iopub.status.idle": "2026-05-06T11:52:57.995893Z", - "shell.execute_reply": "2026-05-06T11:52:57.995691Z" - } - }, + "metadata": {}, "outputs": [], "source": [ - "# x_pts are fuel breakpoints, y_pts are power breakpoints — swap so power is on the x-axis\n", + "# x_pts are fuel breakpoints, y_pts are power breakpoints \u2014 swap so power is on the x-axis\n", "plot_curve(y_pts, x_pts, m.solution[\"power\"].values, m.solution[\"fuel\"].values);" ] }, @@ -288,7 +245,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 5. Unit commitment — `active`\n", + "## 5. Unit commitment \u2014 `active`\n", "\n", "A binary variable gates the whole formulation. `active=0` forces the PWL variables (and thus all linked outputs) to zero. Combined with the natural `lower=0` on cost/fuel/heat, this gives a clean on/off coupling:\n", "\n", @@ -303,12 +260,6 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:59.422636Z", "start_time": "2026-04-22T23:31:59.232150Z" - }, - "execution": { - "iopub.execute_input": "2026-05-06T11:52:57.996958Z", - "iopub.status.busy": "2026-05-06T11:52:57.996888Z", - "iopub.status.idle": "2026-05-06T11:52:58.117758Z", - "shell.execute_reply": "2026-05-06T11:52:58.117569Z" } }, "outputs": [], @@ -328,7 +279,7 @@ " (fuel, y_pts),\n", " active=commit,\n", ")\n", - "# demand below p_min at t=1 — commit must be 0 and backup covers it\n", + "# demand below p_min at t=1 \u2014 commit must be 0 and backup covers it\n", "m.add_constraints(power + backup == xr.DataArray([15, 80, 40], coords=[time]))\n", "m.add_objective(fuel.sum() + 50 * commit.sum() + 200 * backup.sum())\n", "m.solve(reformulate_sos=\"auto\")\n", @@ -338,14 +289,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-06T11:52:58.118797Z", - "iopub.status.busy": "2026-05-06T11:52:58.118738Z", - "iopub.status.idle": "2026-05-06T11:52:58.161329Z", - "shell.execute_reply": "2026-05-06T11:52:58.161120Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "plot_curve(x_pts, y_pts, m.solution[\"power\"].values, m.solution[\"fuel\"].values);" @@ -355,9 +299,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 6. N-variable linking — CHP plant\n", + "## 6. N-variable linking \u2014 CHP plant\n", "\n", - "More than two variables can share the same interpolation — useful for combined heat-and-power plants where power, fuel and heat are all functions of a single operating point." + "More than two variables can share the same interpolation \u2014 useful for combined heat-and-power plants where power, fuel and heat are all functions of a single operating point." ] }, { @@ -367,12 +311,6 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:59.598540Z", "start_time": "2026-04-22T23:31:59.433551Z" - }, - "execution": { - "iopub.execute_input": "2026-05-06T11:52:58.162560Z", - "iopub.status.busy": "2026-05-06T11:52:58.162485Z", - "iopub.status.idle": "2026-05-06T11:52:58.260466Z", - "shell.execute_reply": "2026-05-06T11:52:58.260287Z" } }, "outputs": [], @@ -399,14 +337,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-06T11:52:58.261553Z", - "iopub.status.busy": "2026-05-06T11:52:58.261501Z", - "iopub.status.idle": "2026-05-06T11:52:58.330515Z", - "shell.execute_reply": "2026-05-06T11:52:58.330298Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "fig, axes = plt.subplots(1, 2, figsize=(8, 3))\n", @@ -427,7 +358,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 7. Per-entity breakpoints — a fleet of generators\n", + "## 7. Per-entity breakpoints \u2014 a fleet of generators\n", "\n", "Pass a dict to `breakpoints()` with entity names as keys for different curves per entity. Ragged lengths are NaN-padded automatically, and breakpoints broadcast over any remaining dimensions (here, `time`)." ] @@ -439,12 +370,6 @@ "ExecuteTime": { "end_time": "2026-04-22T23:31:59.801734Z", "start_time": "2026-04-22T23:31:59.606692Z" - }, - "execution": { - "iopub.execute_input": "2026-05-06T11:52:58.331628Z", - "iopub.status.busy": "2026-05-06T11:52:58.331565Z", - "iopub.status.idle": "2026-05-06T11:52:58.452296Z", - "shell.execute_reply": "2026-05-06T11:52:58.452098Z" } }, "outputs": [], @@ -471,9 +396,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 8. Specifying with slopes — `Slopes`\n", + "## 8. Specifying with slopes \u2014 `Slopes`\n", "\n", - "When you know marginal values (slopes) rather than absolute breakpoint y-values, wrap them in `linopy.Slopes`. The x grid is **borrowed from the sibling tuple** in the call — no need to repeat it. The example below reproduces the same gas-turbine fit as section 1, but specified via per-piece slopes plus an initial y-value:" + "When you know marginal values (slopes) rather than absolute breakpoint y-values, wrap them in `linopy.Slopes`. The x grid is **borrowed from the sibling tuple** in the call \u2014 no need to repeat it. The example below reproduces the same gas-turbine fit as section 1, but specified via per-piece slopes plus an initial y-value:" ] }, { @@ -486,14 +411,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-06T11:52:58.453727Z", - "iopub.status.busy": "2026-05-06T11:52:58.453664Z", - "iopub.status.idle": "2026-05-06T11:52:58.455340Z", - "shell.execute_reply": "2026-05-06T11:52:58.455181Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "linopy.Slopes([1.2, 1.6, 2.15], y0=0)" @@ -502,21 +420,14 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-06T11:52:58.456960Z", - "iopub.status.busy": "2026-05-06T11:52:58.456892Z", - "iopub.status.idle": "2026-05-06T11:52:58.549899Z", - "shell.execute_reply": "2026-05-06T11:52:58.549720Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "m = linopy.Model()\n", "power = m.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", "\n", - "# Same curve as section 1 — slopes 1.2, 1.6, 2.15 over x = [0, 30, 60, 100].\n", + "# Same curve as section 1 \u2014 slopes 1.2, 1.6, 2.15 over x = [0, 30, 60, 100].\n", "m.add_piecewise_formulation(\n", " (power, [0, 30, 60, 100]),\n", " (fuel, linopy.Slopes([1.2, 1.6, 2.15], y0=0)),\n", @@ -538,14 +449,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-06T11:52:58.551007Z", - "iopub.status.busy": "2026-05-06T11:52:58.550949Z", - "iopub.status.idle": "2026-05-06T11:52:58.556338Z", - "shell.execute_reply": "2026-05-06T11:52:58.556178Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "linopy.Slopes([1.2, 1.6, 2.15], y0=0).to_breakpoints([0, 30, 60, 100])" diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 3ed19414..b2c4f70a 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -40,6 +40,7 @@ PWL_SELECT_SUFFIX, SEGMENT_DIM, ) +from linopy.piecewise import _slopes_to_points from linopy.solver_capabilities import SolverFeature, get_available_solvers_with_feature if TYPE_CHECKING: @@ -69,18 +70,12 @@ class TestSlopesToPointsPrivate: """ def test_basic(self) -> None: - from linopy.piecewise import _slopes_to_points - assert _slopes_to_points([0, 1, 2], [1, 2], 0) == [0, 1, 3] def test_negative_slopes(self) -> None: - from linopy.piecewise import _slopes_to_points - assert _slopes_to_points([0, 10, 20], [-0.5, -1.0], 10) == [10, 5, -5] def test_wrong_length_raises(self) -> None: - from linopy.piecewise import _slopes_to_points - with pytest.raises(ValueError, match="len\\(slopes\\)"): _slopes_to_points([0, 1, 2], [1], 0) From a88325a74c366af7b0855a6fd4961553d3959986 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 May 2026 14:06:34 +0200 Subject: [PATCH 07/20] =?UTF-8?q?fix(notebook):=20restore=20em-dashes=20fr?= =?UTF-8?q?om=20=E2=80=94=20escapes=20to=20UTF-8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous metadata-strip pass round-tripped the notebook through ``json.dump(..., indent=1)`` which defaults ``ensure_ascii=True`` and escaped all em-dashes (and any other non-ASCII chars) across the whole file — pure encoding churn. Surgical fix: byte-level replace ``—`` → ``—`` rather than another JSON round-trip, so nothing else changes. Future re-encodes should use ``ensure_ascii=False``. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/piecewise-linear-constraints.ipynb | 30 ++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 21d5b39f..7b7d2f76 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -6,7 +6,7 @@ "source": [ "# Piecewise Linear Constraints Tutorial\n", "\n", - "`add_piecewise_formulation` links variables through shared breakpoint weights. Every section below stacks one feature on top of a small shared dispatch pattern \u2014 if you want the math, see the [reference page](piecewise-linear-constraints). For inequality bounds and the LP chord formulation in depth, see the [inequality bounds notebook](piecewise-inequality-bounds-tutorial).\n", + "`add_piecewise_formulation` links variables through shared breakpoint weights. Every section below stacks one feature on top of a small shared dispatch pattern — if you want the math, see the [reference page](piecewise-linear-constraints). For inequality bounds and the LP chord formulation in depth, see the [inequality bounds notebook](piecewise-inequality-bounds-tutorial).\n", "\n", "The baseline we extend:\n", "\n", @@ -107,13 +107,13 @@ "source": [ "## 2. Picking a method\n", "\n", - "`method=\"auto\"` (default) picks the cheapest correct formulation based on sign, curvature and breakpoint layout. The explicit options \u2014 `\"sos2\"`, `\"incremental\"`, `\"lp\"` \u2014 give the same optimum on equality cases where they all apply, so the choice is about **cost** (auxiliary variables, solver capability), not correctness.\n", + "`method=\"auto\"` (default) picks the cheapest correct formulation based on sign, curvature and breakpoint layout. The explicit options — `\"sos2\"`, `\"incremental\"`, `\"lp\"` — give the same optimum on equality cases where they all apply, so the choice is about **cost** (auxiliary variables, solver capability), not correctness.\n", "\n", "| method | needs | creates |\n", "|---|---|---|\n", "| `sos2` | SOS2-capable solver | lambdas (continuous) |\n", "| `incremental` | MIP solver, strictly monotonic breakpoints | deltas (continuous) + binaries |\n", - "| `lp` | any LP solver | no variables \u2014 requires `sign != \"==\"`, 2 tuples, matching curvature |\n", + "| `lp` | any LP solver | no variables — requires `sign != \"==\"`, 2 tuples, matching curvature |\n", "\n", "Below: all applicable methods yield the same fuel dispatch on this convex curve." ] @@ -147,7 +147,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 3. Disjunctive segments \u2014 gaps in the operating range\n", + "## 3. Disjunctive segments — gaps in the operating range\n", "\n", "When operating regions are **disconnected** (a diesel generator that is either off or running in [50, 80] MW, never in between), use `segments()` instead of `breakpoints()`. A binary picks which segment is active; inside it SOS2 interpolates as usual." ] @@ -190,11 +190,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 4. Inequality bounds \u2014 per-tuple sign\n", + "## 4. Inequality bounds — per-tuple sign\n", "\n", "Append a third tuple element (`\"<=\"` or `\">=\"`) to mark a single expression as **bounded** by the piecewise curve instead of pinned to it. The other tuples stay on the curve. The 2-variable hypograph (`y \u2264 f(x)`) and epigraph (`y \u2265 f(x)`) are the canonical cases.\n", "\n", - "On a concave curve with `<=` (or convex with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation \u2014 no binaries, no SOS2.\n", + "On a concave curve with `<=` (or convex with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation — no binaries, no SOS2.\n", "\n", "At most one tuple may carry a non-equality sign. With 3 or more tuples, all signs must be `\"==\"`; the multi-input bounded case is reserved for a future bivariate / triangulated piecewise API.\n", "\n", @@ -237,7 +237,7 @@ "metadata": {}, "outputs": [], "source": [ - "# x_pts are fuel breakpoints, y_pts are power breakpoints \u2014 swap so power is on the x-axis\n", + "# x_pts are fuel breakpoints, y_pts are power breakpoints — swap so power is on the x-axis\n", "plot_curve(y_pts, x_pts, m.solution[\"power\"].values, m.solution[\"fuel\"].values);" ] }, @@ -245,7 +245,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 5. Unit commitment \u2014 `active`\n", + "## 5. Unit commitment — `active`\n", "\n", "A binary variable gates the whole formulation. `active=0` forces the PWL variables (and thus all linked outputs) to zero. Combined with the natural `lower=0` on cost/fuel/heat, this gives a clean on/off coupling:\n", "\n", @@ -279,7 +279,7 @@ " (fuel, y_pts),\n", " active=commit,\n", ")\n", - "# demand below p_min at t=1 \u2014 commit must be 0 and backup covers it\n", + "# demand below p_min at t=1 — commit must be 0 and backup covers it\n", "m.add_constraints(power + backup == xr.DataArray([15, 80, 40], coords=[time]))\n", "m.add_objective(fuel.sum() + 50 * commit.sum() + 200 * backup.sum())\n", "m.solve(reformulate_sos=\"auto\")\n", @@ -299,9 +299,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 6. N-variable linking \u2014 CHP plant\n", + "## 6. N-variable linking — CHP plant\n", "\n", - "More than two variables can share the same interpolation \u2014 useful for combined heat-and-power plants where power, fuel and heat are all functions of a single operating point." + "More than two variables can share the same interpolation — useful for combined heat-and-power plants where power, fuel and heat are all functions of a single operating point." ] }, { @@ -358,7 +358,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 7. Per-entity breakpoints \u2014 a fleet of generators\n", + "## 7. Per-entity breakpoints — a fleet of generators\n", "\n", "Pass a dict to `breakpoints()` with entity names as keys for different curves per entity. Ragged lengths are NaN-padded automatically, and breakpoints broadcast over any remaining dimensions (here, `time`)." ] @@ -396,9 +396,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 8. Specifying with slopes \u2014 `Slopes`\n", + "## 8. Specifying with slopes — `Slopes`\n", "\n", - "When you know marginal values (slopes) rather than absolute breakpoint y-values, wrap them in `linopy.Slopes`. The x grid is **borrowed from the sibling tuple** in the call \u2014 no need to repeat it. The example below reproduces the same gas-turbine fit as section 1, but specified via per-piece slopes plus an initial y-value:" + "When you know marginal values (slopes) rather than absolute breakpoint y-values, wrap them in `linopy.Slopes`. The x grid is **borrowed from the sibling tuple** in the call — no need to repeat it. The example below reproduces the same gas-turbine fit as section 1, but specified via per-piece slopes plus an initial y-value:" ] }, { @@ -427,7 +427,7 @@ "power = m.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", "\n", - "# Same curve as section 1 \u2014 slopes 1.2, 1.6, 2.15 over x = [0, 30, 60, 100].\n", + "# Same curve as section 1 — slopes 1.2, 1.6, 2.15 over x = [0, 30, 60, 100].\n", "m.add_piecewise_formulation(\n", " (power, [0, 30, 60, 100]),\n", " (fuel, linopy.Slopes([1.2, 1.6, 2.15], y0=0)),\n", From a4c041af95db38f4107e963999eedce0752498a8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 May 2026 14:07:57 +0200 Subject: [PATCH 08/20] fix(notebook): restore unrelated unicode chars and Python version metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more accidental edits from the json round-trip caught by reviewing the master diff: * ``≤`` and ``≥`` in section 4 (existing master content) had been escaped to ``≤`` / ``≥``. Restored to UTF-8. * Notebook ``language_info.version`` metadata had drifted from ``"3.13.2"`` (master) to ``"3.11.11"`` (whatever kernel I happened to run). Reverted. Net: the notebook diff vs master is now 63 insertions / 0 deletions — only the four new section-8 cells, no incidental churn. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/piecewise-linear-constraints.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index 7b7d2f76..ace9d7d9 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -192,7 +192,7 @@ "source": [ "## 4. Inequality bounds — per-tuple sign\n", "\n", - "Append a third tuple element (`\"<=\"` or `\">=\"`) to mark a single expression as **bounded** by the piecewise curve instead of pinned to it. The other tuples stay on the curve. The 2-variable hypograph (`y \u2264 f(x)`) and epigraph (`y \u2265 f(x)`) are the canonical cases.\n", + "Append a third tuple element (`\"<=\"` or `\">=\"`) to mark a single expression as **bounded** by the piecewise curve instead of pinned to it. The other tuples stay on the curve. The 2-variable hypograph (`y ≤ f(x)`) and epigraph (`y ≥ f(x)`) are the canonical cases.\n", "\n", "On a concave curve with `<=` (or convex with `>=`), `method=\"auto\"` dispatches to a pure LP chord formulation — no binaries, no SOS2.\n", "\n", @@ -472,7 +472,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.11" + "version": "3.13.2" } }, "nbformat": 4, From 2c001390c8ec2adcd1bcd7971544d0ab0f062ee2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 May 2026 14:54:35 +0200 Subject: [PATCH 09/20] review fixes: emit Slopes warning, bound seq repr, harden dispatch test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review of #673: * **Slopes now actually emits the EvolvingAPIWarning** it advertises in its docstring. The warning fires from ``__post_init__`` so the standalone ``Slopes(...).to_breakpoints(...)`` migration path doesn't silently bypass the evolving-API signal that the previous ``breakpoints(slopes=...)`` form indirectly inherited. ``_EvolvingApiKey`` extended to include ``"Slopes"``; per-key dedup keeps construction cheap on repeated use. * **``_summarise_breakslike`` truncates long sequences** instead of dumping them verbatim. Sequences over 8 entries render as ``[0, 1, 2, ..., 48, 49] (50 items)`` — the previous "small size" comment promised this without enforcing it. * **``test_two_non_slopes_picks_first_x_grid``** previously asserted only that the formulation was registered. Now uses distinguishable x grids (10× scale difference), pins the model onto piece 1, and verifies ``z == 10`` (the value implied by the *first* tuple's grid) rather than ``z == 100`` (the second tuple's). * **New ``test_multiple_slopes_share_x_grid``** covers the ``(non-Slopes, Slopes, Slopes)`` shape — both Slopes resolve against the same borrowed grid. Reviewer-flagged coverage gap. * **New ``test_slopes_construction_warns_and_dedups``** in ``TestEvolvingAPIWarning`` pins the new warning behaviour. * **New ``test_repr_truncates_long_sequences``** in ``TestSlopesValueType`` pins the truncation. * Hoisted ``set(slopes_idx)`` out of the ``non_slopes_idx`` comprehension in the dispatch (cosmetic; N is small). * Added a module-level ``TOL = 1e-6`` constant in ``test_piecewise_constraints.py`` matching the convention in ``test_piecewise_feasibility.py``; the new dispatch test uses it. Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/piecewise.py | 27 ++++++++--- test/test_piecewise_constraints.py | 78 ++++++++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 49155297..956ae3fe 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -63,7 +63,9 @@ # Each user-facing piecewise entry point fires its EvolvingAPIWarning at # most once per process. Without dedup, a single model build emits the # verbose warning hundreds of times and drowns out other output. -_EvolvingApiKey: TypeAlias = Literal["tangent_lines", "add_piecewise_formulation"] +_EvolvingApiKey: TypeAlias = Literal[ + "tangent_lines", "add_piecewise_formulation", "Slopes" +] _emitted_evolving_warnings: set[_EvolvingApiKey] = set() @@ -148,6 +150,14 @@ class Slopes: align: Literal["pieces", "leading"] = "pieces" dim: str | None = None + def __post_init__(self) -> None: + _warn_evolving_api( + "Slopes", + "piecewise: Slopes is a new API; the constructor signature and " + "the dispatch rules for inheriting an x grid from sibling tuples " + "may be refined in minor releases.", + ) + def to_breakpoints(self, x_points: BreaksLike) -> DataArray: """ Resolve to a breakpoint :class:`xarray.DataArray`, given an x grid. @@ -181,8 +191,13 @@ def _summarise_breakslike(v: BreaksLike) -> str: return f"" if isinstance(v, dict): return f"" - # Sequence[float] (list, tuple, ndarray of small size) — render inline. - return repr(v) + # Sequence[float] — render inline up to 8 entries; longer truncates. + seq = list(v) + if len(seq) <= 8: + return repr(seq) + head = ", ".join(repr(x) for x in seq[:3]) + tail = ", ".join(repr(x) for x in seq[-2:]) + return f"[{head}, ..., {tail}] ({len(seq)} items)" # Tuple element type covering both eager (DataArray etc.) and deferred (Slopes) bps. @@ -1054,9 +1069,9 @@ def add_piecewise_formulation( # first non-Slopes tuple. All non-Slopes tuples share the same # BREAKPOINT_DIM (validated downstream), so picking the first is # unambiguous. - slopes_idx = [i for i, p in enumerate(parsed) if isinstance(p[1], Slopes)] - if slopes_idx: - non_slopes_idx = [i for i in range(len(parsed)) if i not in set(slopes_idx)] + slopes_set = {i for i, p in enumerate(parsed) if isinstance(p[1], Slopes)} + if slopes_set: + non_slopes_idx = [i for i in range(len(parsed)) if i not in slopes_set] if not non_slopes_idx: raise ValueError( "All tuples are Slopes; at least one tuple must carry an " diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index b2c4f70a..4e1b2b9a 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -56,6 +56,10 @@ s for s in ["highs", "gurobi", "glpk", "cplex"] if s in available_solvers ] +# Solver-output tolerance for solution-value assertions in this file. Matches +# the convention in ``test_piecewise_feasibility.py``. +TOL = 1e-6 + # =========================================================================== # _slopes_to_points (private list utility) @@ -187,6 +191,17 @@ def test_repr_renders(self, kwargs: dict[str, Any], expected: str) -> None: else: assert expected in r + def test_repr_truncates_long_sequences(self) -> None: + """Lists/ndarrays over 8 entries must be summarised, not dumped.""" + from linopy import Slopes + + r = repr(Slopes(list(range(50)), y0=0)) + # No 50-element dump — must include the "(50 items)" suffix and + # contain at most a handful of explicit numbers. + assert "(50 items)" in r + assert "..." in r + assert len(r) < 80, f"repr unexpectedly long: {r!r}" + @pytest.mark.parametrize( ("values", "fragment"), [ @@ -524,20 +539,55 @@ def test_all_slopes_raises(self) -> None: (y, Slopes([1, 1], y0=0)), ) + @pytest.mark.skipif(not _any_solvers, reason="no solver available") def test_two_non_slopes_picks_first_x_grid(self) -> None: - """With multiple non-Slopes tuples, deterministic pick from the first.""" + """ + With multiple non-Slopes tuples, the Slopes resolution must borrow + the x grid from the *first* non-Slopes tuple (deterministic). + + Pin this with distinguishable grids: x is [0, 10, 20, 30] and y + is [0, 100, 200, 300] (10× larger). Slopes ``[1, 1, 1]`` with + ``y0=0`` resolves to ``[0, 10, 20, 30]`` if borrowed from x and + ``[0, 100, 200, 300]`` if borrowed from y. Forcing the model + onto piece 1 (x == 10, hence y == 100) and solving must yield + z == 10 (matching the *first* — x — grid). + """ + from linopy import Slopes + + m = Model() + x = m.add_variables(lower=0, upper=30, name="x") + y = m.add_variables(lower=0, upper=300, name="y") + z = m.add_variables(lower=0, upper=300, name="z") + m.add_piecewise_formulation( + (x, [0, 10, 20, 30]), # first non-Slopes — should be the source + (y, [0, 100, 200, 300]), # second non-Slopes — must NOT be picked + (z, Slopes([1, 1, 1], y0=0)), + ) + m.add_constraints(x == 10) + m.add_objective(z) # any feasible objective; equality pins z anyway + m.solve() + assert float(m.solution["z"]) == pytest.approx(10.0, abs=TOL) + assert float(m.solution["y"]) == pytest.approx(100.0, abs=TOL) + + def test_multiple_slopes_share_x_grid(self) -> None: + """ + Two Slopes tuples plus one non-Slopes — both Slopes resolve against + the same borrowed x grid. Pin via distinct slope sequences so the + two Slopes-derived variables end up with different breakpoint values. + """ from linopy import Slopes m = Model() x = m.add_variables(name="x") y = m.add_variables(name="y") z = m.add_variables(name="z") - # x and y both carry the same grid (size 4); z borrows it f = m.add_piecewise_formulation( - (x, [0, 30, 60, 100]), - (y, [0, 40, 85, 160]), - (z, Slopes([0.5, 0.6, 0.7], y0=0)), + (x, [0, 10, 20, 30]), + (y, Slopes([1, 1, 1], y0=0)), # → [0, 10, 20, 30] + (z, Slopes([2, 2, 2], y0=0)), # → [0, 20, 40, 60] ) + # 3-var formulation -> convexity is None. + assert f.convexity is None assert f.name in m._piecewise_formulations def test_slopes_align_leading_in_dispatch(self) -> None: @@ -2742,6 +2792,24 @@ def test_tangent_lines_warns_and_dedups_independently(self) -> None: assert len(evolving) == 1 assert "tangent_lines" in str(evolving[0].message) + def test_slopes_construction_warns_and_dedups(self) -> None: + """ + ``Slopes(...)`` is part of the same evolving API surface and emits + on construction so that the standalone ``Slopes(...).to_breakpoints(...)`` + path doesn't silently bypass the signal. Per-key dedup keeps it + quiet for repeated use. + """ + from linopy import EvolvingAPIWarning, Slopes + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", EvolvingAPIWarning) + Slopes([1, 2], y0=0) + Slopes([3, 4], y0=5) + Slopes([1, 1, 1], y0=0, align="leading") + evolving = [w for w in caught if issubclass(w.category, EvolvingAPIWarning)] + assert len(evolving) == 1 + assert "Slopes" in str(evolving[0].message) + def test_warning_stacklevel_points_to_user_call(self) -> None: """ ``stacklevel=3`` in ``_warn_evolving_api`` should make the warning From f27ee49a4a1ae206403934093fc90c2f5799c906 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 May 2026 15:18:15 +0200 Subject: [PATCH 10/20] fix(piecewise): three robustness issues in Slopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. **Stacklevel was off by one** for warnings emitted from ``Slopes.__post_init__``. The dataclass-generated ``__init__`` adds an extra frame (helper → ``_warn_evolving_api`` → ``__post_init__`` → synthetic ``__init__`` → user code), so ``stacklevel=3`` landed inside the synthetic init instead of the user's call site. Made ``_warn_evolving_api`` accept ``stacklevel`` as a parameter (default 3, matching the function-call entry points) and pass ``stacklevel=4`` from ``Slopes``. 2. **Equality crashed with array values.** Frozen dataclasses default to elementwise ``__eq__``, so ``Slopes(np.array([1, 2])) == Slopes(np.array([1, 2]))`` raised ``ValueError: truth value of an array with more than one element is ambiguous``. Added ``eq=False`` to opt out and fall back to identity equality. ``Slopes`` is now safely usable as a set member or dict key. 3. **Numpy scalar repr noise.** ``_summarise_breakslike`` previously called ``list(v)`` which preserved numpy scalar types; their reprs differ from Python scalars (and across numpy versions). Switched to ``np.asarray(v).tolist()`` which normalises numpy types to Python types up front, so ``Slopes(np.array([1, 2, 3], dtype=np.int64), y0=0)`` renders as ``Slopes([1, 2, 3], y0=0)`` uniformly. Added a 0-D guard for the edge case. Each fix is pinned by a new test in ``TestSlopesValueType`` (``test_repr_normalises_numpy_scalars``, ``test_equality_with_array_values_does_not_raise``) and ``TestEvolvingAPIWarning`` (``test_slopes_warning_stacklevel_points_to_user_call``). Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/piecewise.py | 41 ++++++++++++++++++++++------- test/test_piecewise_constraints.py | 42 ++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 956ae3fe..89d92cf7 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -69,12 +69,19 @@ _emitted_evolving_warnings: set[_EvolvingApiKey] = set() -def _warn_evolving_api(key: _EvolvingApiKey, message: str) -> None: - """Emit an :class:`EvolvingAPIWarning` at most once per session per ``key``.""" +def _warn_evolving_api(key: _EvolvingApiKey, message: str, stacklevel: int = 3) -> None: + """ + Emit an :class:`EvolvingAPIWarning` at most once per session per ``key``. + + ``stacklevel`` defaults to 3 (helper → entry-point function → user + code). Pass a larger value when called from one frame deeper than + a function — e.g. from a dataclass ``__post_init__``, which is + itself invoked by an auto-generated ``__init__``. + """ if key in _emitted_evolving_warnings: return _emitted_evolving_warnings.add(key) - warnings.warn(message, category=EvolvingAPIWarning, stacklevel=3) + warnings.warn(message, category=EvolvingAPIWarning, stacklevel=stacklevel) # Accepted input types for breakpoint-like data @@ -96,7 +103,7 @@ def _warn_evolving_api(key: _EvolvingApiKey, message: str) -> None: # --------------------------------------------------------------------------- -@dataclass(frozen=True, slots=True, repr=False) +@dataclass(frozen=True, slots=True, repr=False, eq=False) class Slopes: """ Per-piece slopes + initial y-value, deferred until an x grid is known. @@ -151,11 +158,14 @@ class Slopes: dim: str | None = None def __post_init__(self) -> None: + # ``stacklevel=4``: warn → _warn_evolving_api → __post_init__ → + # dataclass-generated ``__init__`` → user code. _warn_evolving_api( "Slopes", "piecewise: Slopes is a new API; the constructor signature and " "the dispatch rules for inheriting an x grid from sibling tuples " "may be refined in minor releases.", + stacklevel=4, ) def to_breakpoints(self, x_points: BreaksLike) -> DataArray: @@ -191,15 +201,28 @@ def _summarise_breakslike(v: BreaksLike) -> str: return f"" if isinstance(v, dict): return f"" - # Sequence[float] — render inline up to 8 entries; longer truncates. - seq = list(v) + # Sequence[float] — render inline up to 8 entries (shortest float + # format, ``g``); longer truncates to head + tail with item count. + # ``np.asarray(...).tolist()`` normalises numpy scalars (e.g. + # ``np.float64`` / ``np.int64``) to Python types so the rendering + # is uniform regardless of input dtype. + seq = np.asarray(v).tolist() + if not isinstance(seq, list): # 0-D ndarray → scalar + return _short_num(seq) if len(seq) <= 8: - return repr(seq) - head = ", ".join(repr(x) for x in seq[:3]) - tail = ", ".join(repr(x) for x in seq[-2:]) + return "[" + ", ".join(_short_num(x) for x in seq) + "]" + head = ", ".join(_short_num(x) for x in seq[:3]) + tail = ", ".join(_short_num(x) for x in seq[-2:]) return f"[{head}, ..., {tail}] ({len(seq)} items)" +def _short_num(x: object) -> str: + """Compact number formatting for repr — ``g`` for floats, ``repr`` else.""" + if isinstance(x, float): + return f"{x:g}" + return repr(x) + + # Tuple element type covering both eager (DataArray etc.) and deferred (Slopes) bps. BreaksOrSlopes: TypeAlias = BreaksLike | Slopes diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 4e1b2b9a..f5a8c97c 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -202,6 +202,32 @@ def test_repr_truncates_long_sequences(self) -> None: assert "..." in r assert len(r) < 80, f"repr unexpectedly long: {r!r}" + def test_repr_normalises_numpy_scalars(self) -> None: + """``np.int64`` / ``np.float64`` must render as plain Python numbers.""" + from linopy import Slopes + + r_int = repr(Slopes(np.array([1, 2, 3], dtype=np.int64), y0=0)) + r_float = repr(Slopes(np.array([1.5, 2.5, 3.5]), y0=0)) + # No numpy type prefixes, no surprising precision. + assert "np." not in r_int and "int64" not in r_int + assert r_int == "Slopes([1, 2, 3], y0=0)" + assert r_float == "Slopes([1.5, 2.5, 3.5], y0=0)" + + def test_equality_with_array_values_does_not_raise(self) -> None: + """ + Frozen dataclasses default to elementwise ``__eq__``, which raises + on numpy arrays. ``Slopes`` opts out (``eq=False``) and falls + back to identity equality so callers can put it in sets / use it + as a dict key without incident. + """ + from linopy import Slopes + + s1 = Slopes(np.array([1, 2]), y0=0) + s2 = Slopes(np.array([1, 2]), y0=0) + # Must not raise. + assert (s1 == s2) is False # identity + assert (s1 == s1) is True + @pytest.mark.parametrize( ("values", "fragment"), [ @@ -2810,6 +2836,22 @@ def test_slopes_construction_warns_and_dedups(self) -> None: assert len(evolving) == 1 assert "Slopes" in str(evolving[0].message) + def test_slopes_warning_stacklevel_points_to_user_call(self) -> None: + """ + ``Slopes.__post_init__`` emits via a dataclass-generated ``__init__`` + — ``_warn_evolving_api`` needs ``stacklevel=4`` to skip the helper, + ``__post_init__``, and the synthetic init and land on the actual + user line. + """ + from linopy import EvolvingAPIWarning, Slopes + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", EvolvingAPIWarning) + Slopes([1, 2], y0=0) + evolving = [w for w in caught if issubclass(w.category, EvolvingAPIWarning)] + assert len(evolving) == 1 + assert evolving[0].filename.endswith("test_piecewise_constraints.py") + def test_warning_stacklevel_points_to_user_call(self) -> None: """ ``stacklevel=3`` in ``_warn_evolving_api`` should make the warning From 63c4126fbb1c560e2c92e8b9eb689fecb28674b2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 May 2026 15:30:18 +0200 Subject: [PATCH 11/20] feat(piecewise): value-equality on Slopes via type-dispatched __eq__ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier ``eq=False`` (identity equality) was a footgun for tests: ``assert pwf_spec == expected_slopes`` would silently return ``False`` even when the two specs described the same curve. Replace with a custom ``__eq__`` that compares each field by value: * ``align`` / ``dim`` — plain ``==``. * ``y0`` / ``values`` — dispatched on type via ``_values_equal``: - ``ndarray`` → ``np.array_equal(equal_nan=True)`` - ``DataFrame`` / ``Series`` → ``.equals(...)`` - ``DataArray`` → ``.equals(...)`` - ``dict`` → recurse on matching keys - scalar ``float`` → NaN-safe ``==`` (treats nan==nan as ``True`` to match the array path's ``equal_nan=True``) - everything else → strict ``type(a) is type(b)`` then ``==``. ``__hash__`` set to ``None`` (unhashable) since ``values`` may be a mutable container. Documented edges: * List vs ndarray of the same numeric content compare unequal — strict type matching, same as Python's general ``[1,2] != np.array([1,2])`` behaviour. Tests: parametrised ``TestSlopesValueType.test_equality`` covers nine shapes (lists, ndarrays, dicts, NaN scalars, NaN in arrays, mismatched y0, mismatched values, mismatched types, dict inner-value mismatch). Plus ``test_eq_against_non_slopes_returns_notimplemented`` for the non-Slopes branch and ``test_unhashable`` pinning the hash opt-out. Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/piecewise.py | 55 ++++++++++++++++++ test/test_piecewise_constraints.py | 92 ++++++++++++++++++++++++++---- 2 files changed, 137 insertions(+), 10 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 89d92cf7..d9addee8 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -189,6 +189,61 @@ def __repr__(self) -> str: bits.append(f"dim={self.dim!r}") return f"Slopes({', '.join(bits)})" + def __eq__(self, other: object) -> bool: + """ + Value-equality across the field types accepted by the constructor. + + Two ``Slopes`` are equal iff every field matches: + + * ``align`` and ``dim`` compare with ``==`` (str / None). + * ``y0`` and ``values`` dispatch on type via :func:`_values_equal` + — ``np.array_equal(equal_nan=True)`` for ndarrays, ``.equals`` + for pandas/xarray, recursive for ``dict``, NaN-safe ``==`` for + scalars. + + Non-``Slopes`` operands return ``NotImplemented`` per Python + convention. Different *types* of ``values`` (e.g. ``list`` vs + ``ndarray``) are *not* coerced — they compare unequal. + + ``__hash__`` is set to ``None`` (unhashable) since the inner + ``values`` may be a mutable container. + """ + if not isinstance(other, Slopes): + return NotImplemented + return ( + self.align == other.align + and self.dim == other.dim + and _values_equal(self.y0, other.y0) + and _values_equal(self.values, other.values) + ) + + __hash__ = None # type: ignore[assignment] + + +def _values_equal(a: object, b: object) -> bool: + """Type-dispatched equality for ``Slopes`` field values (NaN-safe).""" + if isinstance(a, np.ndarray): + if not isinstance(b, np.ndarray): + return False + return bool(np.array_equal(a, b, equal_nan=True)) + if isinstance(a, pd.DataFrame): + return isinstance(b, pd.DataFrame) and bool(a.equals(b)) + if isinstance(a, pd.Series): + return isinstance(b, pd.Series) and bool(a.equals(b)) + if isinstance(a, DataArray): + return isinstance(b, DataArray) and bool(a.equals(b)) + if isinstance(a, dict): + if not isinstance(b, dict) or a.keys() != b.keys(): + return False + return all(_values_equal(a[k], b[k]) for k in a) + if type(a) is not type(b): + return False + if isinstance(a, float): + # IEEE: nan != nan. Treat two nans as equal (matches the array path). + if a != a: # nan check + return b != b + return a == b + def _summarise_breakslike(v: BreaksLike) -> str: """Compact one-line summary of a BreaksLike value for use in reprs.""" diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index f5a8c97c..4cc739cd 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -213,20 +213,92 @@ def test_repr_normalises_numpy_scalars(self) -> None: assert r_int == "Slopes([1, 2, 3], y0=0)" assert r_float == "Slopes([1.5, 2.5, 3.5], y0=0)" - def test_equality_with_array_values_does_not_raise(self) -> None: + @pytest.mark.parametrize( + ("a", "b", "expected"), + [ + pytest.param( + {"values": [1, 2], "y0": 0}, + {"values": [1, 2], "y0": 0}, + True, + id="lists_equal", + ), + pytest.param( + {"values": np.array([1, 2]), "y0": 0}, + {"values": np.array([1, 2]), "y0": 0}, + True, + id="ndarrays_equal_no_raise", + ), + pytest.param( + {"values": [1, 2], "y0": 0}, + {"values": [1, 3], "y0": 0}, + False, + id="different_values", + ), + pytest.param( + {"values": [1, 2], "y0": 0}, + {"values": [1, 2], "y0": 5}, + False, + id="different_y0", + ), + pytest.param( + # list vs ndarray of same numeric content — strict types, + # documented edge. + {"values": [1, 2], "y0": 0}, + {"values": np.array([1, 2]), "y0": 0}, + False, + id="different_value_types", + ), + pytest.param( + {"values": [np.nan, 1], "y0": 0, "align": "leading"}, + {"values": [np.nan, 1], "y0": 0, "align": "leading"}, + True, + id="nan_in_list_via_array_path", + ), + pytest.param( + {"values": [1, 2], "y0": float("nan")}, + {"values": [1, 2], "y0": float("nan")}, + True, + id="nan_in_scalar_y0", + ), + pytest.param( + {"values": {"a": [1, 2], "b": [3, 4]}, "y0": 0, "dim": "g"}, + {"values": {"a": [1, 2], "b": [3, 4]}, "y0": 0, "dim": "g"}, + True, + id="dict_equal", + ), + pytest.param( + {"values": {"a": [1, 2]}, "y0": 0, "dim": "g"}, + {"values": {"a": [9, 9]}, "y0": 0, "dim": "g"}, + False, + id="dict_different_inner_values", + ), + ], + ) + def test_equality( + self, a: dict[str, Any], b: dict[str, Any], expected: bool + ) -> None: + """Value-equality across the field types accepted by the constructor.""" + from linopy import Slopes + + assert (Slopes(**a) == Slopes(**b)) is expected + + def test_eq_against_non_slopes_returns_notimplemented(self) -> None: + from linopy import Slopes + + # Falls through to bool(False), not raising. + assert (Slopes([1, 2], y0=0) == "not a slopes") is False + assert (Slopes([1, 2], y0=0) == 42) is False + + def test_unhashable(self) -> None: """ - Frozen dataclasses default to elementwise ``__eq__``, which raises - on numpy arrays. ``Slopes`` opts out (``eq=False``) and falls - back to identity equality so callers can put it in sets / use it - as a dict key without incident. + ``values`` may be a mutable container (list, ndarray, dict), so + ``Slopes`` is intentionally unhashable. Using one as a dict key + or set member must raise rather than silently using identity hash. """ from linopy import Slopes - s1 = Slopes(np.array([1, 2]), y0=0) - s2 = Slopes(np.array([1, 2]), y0=0) - # Must not raise. - assert (s1 == s2) is False # identity - assert (s1 == s1) is True + with pytest.raises(TypeError, match="unhashable"): + {Slopes([1, 2], y0=0): "x"} @pytest.mark.parametrize( ("values", "fragment"), From 207201408f1ca7350e12defd7f12f5a34e9d3942 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 May 2026 15:40:12 +0200 Subject: [PATCH 12/20] feat(piecewise): summarise multi-dim ndarray Slopes values by shape Previously a multi-dim ndarray fell through to the seq path, ``np.asarray(v).tolist()`` returned nested lists, and the repr dumped them in full. Even a moderate ``np.zeros((5, 20))`` produced a 2-line wall of ``0.0`` entries; an earlier ``np.zeros((20, 5, 30))`` case would have been worse. Treat 2-D+ ndarrays the same way ``DataArray`` / ``DataFrame`` / ``Series`` are treated: a one-line shape summary (````). 1-D ndarrays still render inline with the existing head + tail truncation, so user-facing slope specifications stay readable. The ``np.asarray(v)`` call is hoisted so we don't double-normalise on the 1-D path. New parametrised case ``multi_dim_ndarray`` in ``TestSlopesValueType.test_repr_summarises_bulky_values`` pins the new behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/piecewise.py | 17 +++++++++++------ test/test_piecewise_constraints.py | 5 +++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index d9addee8..c97aeb4f 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -256,12 +256,17 @@ def _summarise_breakslike(v: BreaksLike) -> str: return f"" if isinstance(v, dict): return f"" - # Sequence[float] — render inline up to 8 entries (shortest float - # format, ``g``); longer truncates to head + tail with item count. - # ``np.asarray(...).tolist()`` normalises numpy scalars (e.g. - # ``np.float64`` / ``np.int64``) to Python types so the rendering - # is uniform regardless of input dtype. - seq = np.asarray(v).tolist() + # Multi-dim ndarray: shape summary (consistent with DataArray / + # DataFrame / Series treatment above). ``np.asarray`` is called once + # so we don't double-normalise on the 1D path below. + arr = np.asarray(v) + if arr.ndim > 1: + return f"" + # 1D path: render inline up to 8 entries (shortest float format, + # ``g``); longer truncates to head + tail with item count. + # ``arr.tolist()`` normalises numpy scalars (e.g. ``np.float64`` / + # ``np.int64``) to Python types so the rendering is uniform. + seq = arr.tolist() if not isinstance(seq, list): # 0-D ndarray → scalar return _short_num(seq) if len(seq) <= 8: diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 4cc739cd..b7d1878d 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -327,6 +327,11 @@ def test_unhashable(self) -> None: "", id="dict", ), + pytest.param( + np.zeros((20, 5, 30)), + "", + id="multi_dim_ndarray", + ), ], ) def test_repr_summarises_bulky_values( From bb3db50183e7cb14d8809ab2009d0c3806a5d993 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 May 2026 16:04:39 +0200 Subject: [PATCH 13/20] fix(piecewise): broaden Slopes equality, trim release-notes entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Equality (``Slopes.__eq__`` via ``_values_equal``) was strict-type to a fault. Four edge cases produced surprising ``False`` results despite the operands describing the same curve: 1. ``Slopes(y0=0) != Slopes(y0=0.0)`` — ``int`` and ``float`` are semantically the same y-coordinate (``_breakpoints_from_slopes`` calls ``float(y0)`` downstream), but the strict ``type(a) is type(b)`` gate rejected them. 2. ``Slopes(y0=np.float64(0)) != Slopes(y0=0.0)`` — same root cause for numpy scalars. 3. ``Slopes([float('nan'), 1.0], align='leading')`` was unequal to itself — Python's list equality uses ``is`` before ``==`` per element, so it only worked accidentally when the user happened to write ``np.nan`` (a CPython singleton) instead of ``float('nan')``. 4. ``np.array_equal(..., equal_nan=True)`` raises ``TypeError`` on object/string ndarrays. Rewrite ``_values_equal`` to: * Treat any two ``numbers.Real`` (excluding ``bool``) as numerically comparable with a NaN-safe float fallback. * Promote ``list`` / ``tuple`` to ndarray before the array branch so in-place ``float('nan')`` content compares element-wise NaN-safe. * Fall back to ``np.array_equal`` without ``equal_nan`` when the array has a non-numeric dtype. Document the new semantics on ``__eq__`` and explicitly note that ``.equals`` for pandas / xarray containers is order-sensitive. Tests: * Flip ``different_value_types`` (now ``list_and_ndarray_same_content``) to expect ``True``. * Rename ``nan_in_list_via_array_path`` → ``np_nan_in_list``; add parallel ``float_nan_in_list`` case. * Add ``int_and_float_y0`` and ``numpy_scalar_and_float_y0`` cases. * Add ``test_eq_dataframe_is_order_sensitive`` pinning the documented ``.equals`` caveat. * Add ``test_eq_object_dtype_ndarray_does_not_raise`` covering the non-numeric ndarray fallback path. Release notes: trim the ``Slopes`` entry to the user-facing purpose (specify a curve by marginal costs / per-piece slopes) and the canonical call form. Drop the dev-cycle "**replaces** the slopes mode of ``breakpoints()``..." sentence — those API surfaces never shipped, so v0.7.0 readers have no context for the removal note. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/release_notes.rst | 2 +- linopy/piecewise.py | 65 ++++++++++++++++++++++++------ test/test_piecewise_constraints.py | 52 +++++++++++++++++++++--- 3 files changed, 100 insertions(+), 19 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 216c643e..88180844 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -17,7 +17,7 @@ Upcoming Version * Surface formulation metadata on the returned ``PiecewiseFormulation``: ``.method`` (resolved method name) and ``.convexity`` (``"convex"`` / ``"concave"`` / ``"linear"`` / ``"mixed"`` when well-defined). Both persist across netCDF round-trip. * Add ``tangent_lines()`` as a low-level helper that returns per-piece chord expressions as a ``LinearExpression`` — no variables created. Most users should prefer ``add_piecewise_formulation`` with a bounded tuple ``(y, y_pts, "<=")``, which builds on this helper and adds domain bounds and curvature validation. * Add ``linopy.breakpoints()`` (lists/Series/DataFrame/DataArray/dict) and ``linopy.segments()`` (disjunctive operating regions) as breakpoint-construction helpers. -* Add ``linopy.Slopes`` as a deferred breakpoint spec carrying per-piece slopes plus an initial y-value: ``Slopes([1.2, 1.4, 1.7], y0=0)``. Pass it as a tuple element in ``add_piecewise_formulation`` to inherit the x grid from a sibling tuple, or call ``Slopes(...).to_breakpoints(x_pts)`` for standalone resolution. Supports the same shape variations (1D, dict, DataFrame, DataArray, per-entity y0) and the ``align="leading"`` mode that ``breakpoints(slopes=...)`` covered. This **replaces** the slopes mode of ``breakpoints()`` (``slopes=``, ``x_points=``, ``y0=``, ``slopes_align=``) and the standalone ``slopes_to_points()`` helper, both shipped earlier in this development cycle and not yet released. +* Add ``linopy.Slopes`` for specifying a piecewise curve by marginal costs / per-piece slopes instead of absolute y-values — ``(fuel, Slopes([1.2, 1.4, 1.7], y0=0))`` borrows the x grid from a sibling tuple in ``add_piecewise_formulation``. * 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/linopy/piecewise.py b/linopy/piecewise.py index c97aeb4f..d17b576c 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -196,14 +196,27 @@ def __eq__(self, other: object) -> bool: Two ``Slopes`` are equal iff every field matches: * ``align`` and ``dim`` compare with ``==`` (str / None). - * ``y0`` and ``values`` dispatch on type via :func:`_values_equal` - — ``np.array_equal(equal_nan=True)`` for ndarrays, ``.equals`` - for pandas/xarray, recursive for ``dict``, NaN-safe ``==`` for - scalars. + * ``y0`` and ``values`` dispatch on type via :func:`_values_equal`: + numeric scalars compare by value across types (``int 0 == + float 0.0 == np.float64(0)``); ``list`` and ``tuple`` are + promoted to ndarray so NaN content compares element-wise + regardless of which NaN object was used; ndarrays use + ``np.array_equal(equal_nan=True)`` (with a fallback for + non-numeric dtypes); ``pd.Series`` / ``pd.DataFrame`` / + ``DataArray`` use ``.equals``; ``dict`` recurses on matching + keys. Non-``Slopes`` operands return ``NotImplemented`` per Python - convention. Different *types* of ``values`` (e.g. ``list`` vs - ``ndarray``) are *not* coerced — they compare unequal. + convention. + + Caveats + ------- + * ``Series.equals`` / ``DataFrame.equals`` / ``DataArray.equals`` + are *order-sensitive*: two frames with the same content but + reordered rows / columns / coords compare unequal. + * Cross-container coercion is limited to ``list``/``tuple`` → + ndarray. A ``dict`` and a ``DataFrame`` describing the same + per-entity slopes still compare unequal. ``__hash__`` is set to ``None`` (unhashable) since the inner ``values`` may be a mutable container. @@ -221,11 +234,41 @@ def __eq__(self, other: object) -> bool: def _values_equal(a: object, b: object) -> bool: - """Type-dispatched equality for ``Slopes`` field values (NaN-safe).""" + """ + Type-dispatched equality for ``Slopes`` field values (NaN-safe). + + Numeric scalars compare by value (``int 0 == float 0.0 == np.float64(0)``) + with a NaN-safe fallback. Lists / tuples are promoted to ndarray so + in-place ``float('nan')`` content compares element-wise NaN-safe rather + than relying on the ``np.nan`` singleton. Non-numeric ndarray dtypes + (object, string) fall back to a plain ``np.array_equal`` without + ``equal_nan``. + """ + # Numeric scalars (int/float/np.integer/np.floating) — coerce and compare. + # ``bool`` is a subclass of ``int``; exclude so True/False compare strictly. + if ( + isinstance(a, Real) + and not isinstance(a, bool) + and isinstance(b, Real) + and not isinstance(b, bool) + ): + af, bf = float(a), float(b) + return (af != af and bf != bf) or af == bf + + # Promote list/tuple so the ndarray path handles NaN content uniformly. + if isinstance(a, list | tuple): + a = np.asarray(a) + if isinstance(b, list | tuple): + b = np.asarray(b) + if isinstance(a, np.ndarray): - if not isinstance(b, np.ndarray): + if not isinstance(b, np.ndarray) or a.shape != b.shape: return False - return bool(np.array_equal(a, b, equal_nan=True)) + try: + return bool(np.array_equal(a, b, equal_nan=True)) + except TypeError: + # Object / string dtype: ``equal_nan`` raises on isnan. + return bool(np.array_equal(a, b)) if isinstance(a, pd.DataFrame): return isinstance(b, pd.DataFrame) and bool(a.equals(b)) if isinstance(a, pd.Series): @@ -238,10 +281,6 @@ def _values_equal(a: object, b: object) -> bool: return all(_values_equal(a[k], b[k]) for k in a) if type(a) is not type(b): return False - if isinstance(a, float): - # IEEE: nan != nan. Treat two nans as equal (matches the array path). - if a != a: # nan check - return b != b return a == b diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index b7d1878d..09204295 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -241,18 +241,41 @@ def test_repr_normalises_numpy_scalars(self) -> None: id="different_y0", ), pytest.param( - # list vs ndarray of same numeric content — strict types, - # documented edge. + # list and ndarray of same numeric content — list/tuple + # are promoted to ndarray, so they compare equal. {"values": [1, 2], "y0": 0}, {"values": np.array([1, 2]), "y0": 0}, - False, - id="different_value_types", + True, + id="list_and_ndarray_same_content", + ), + pytest.param( + # int and float y0 describe the same curve — Real scalars + # coerce numerically. + {"values": [1, 2], "y0": 0}, + {"values": [1, 2], "y0": 0.0}, + True, + id="int_and_float_y0", + ), + pytest.param( + # numpy scalar y0 vs Python float — same numeric value. + {"values": [1, 2], "y0": np.float64(0)}, + {"values": [1, 2], "y0": 0.0}, + True, + id="numpy_scalar_and_float_y0", + ), + pytest.param( + # In-place ``float('nan')`` (not the np.nan singleton) must + # still compare equal — the array-path promotion handles it. + {"values": [float("nan"), 1.0], "y0": 0, "align": "leading"}, + {"values": [float("nan"), 1.0], "y0": 0, "align": "leading"}, + True, + id="float_nan_in_list", ), pytest.param( {"values": [np.nan, 1], "y0": 0, "align": "leading"}, {"values": [np.nan, 1], "y0": 0, "align": "leading"}, True, - id="nan_in_list_via_array_path", + id="np_nan_in_list", ), pytest.param( {"values": [1, 2], "y0": float("nan")}, @@ -289,6 +312,25 @@ def test_eq_against_non_slopes_returns_notimplemented(self) -> None: assert (Slopes([1, 2], y0=0) == "not a slopes") is False assert (Slopes([1, 2], y0=0) == 42) is False + def test_eq_dataframe_is_order_sensitive(self) -> None: + """``DataFrame.equals`` is order-sensitive — pin the documented caveat.""" + from linopy import Slopes + + df1 = pd.DataFrame({"a": [1, 0.5], "b": [2, 1]}).T + df2 = df1.loc[["b", "a"]] + assert (Slopes(df1, y0=0, dim="g") == Slopes(df2, y0=0, dim="g")) is False + + def test_eq_object_dtype_ndarray_does_not_raise(self) -> None: + """Object/string-dtype ndarrays fall back to plain array_equal.""" + from linopy import Slopes + + a = np.array(["x", "y"], dtype=object) + b = np.array(["x", "y"], dtype=object) + c = np.array(["x", "z"], dtype=object) + # Equal content -> True; different content -> False; neither raises. + assert (Slopes(a, y0=0) == Slopes(b, y0=0)) is True + assert (Slopes(a, y0=0) == Slopes(c, y0=0)) is False + def test_unhashable(self) -> None: """ ``values`` may be a mutable container (list, ndarray, dict), so From 9f9f3cc0b74a37016e9107a79688e74e7203e56e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 May 2026 16:10:45 +0200 Subject: [PATCH 14/20] docs(piecewise): trim notebook section 8 to match the surrounding shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 8 was 6 cells where 2 do the same job — the surrounding sections (1, 7) all use the 1-markdown-intro + 1-code-cell pattern. Drops: * The repr-explanation markdown + a standalone ``Slopes(...)`` cell showing the repr. The repr is incidental; users will see it whenever they instantiate a ``Slopes``. * The ``to_breakpoints`` intro markdown and demo cell. Standalone resolution is documented in the ``.rst`` page; the notebook should show the canonical ``add_piecewise_formulation`` use only. * The ``# Same curve as section 1 — slopes 1.2, 1.6, 2.15 …`` inline comment, now that the markdown intro says the same thing. Also tighten the markdown intro: drop the bold emphasis on "borrowed from the sibling tuple" and the trailing transition sentence. Net result: section-8 diff vs master drops from 63 lines to 30 (roughly halved), and the section now mirrors the visual rhythm of the rest of the tutorial. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/piecewise-linear-constraints.ipynb | 35 +-------------------- 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index ace9d7d9..392ca8f1 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -398,23 +398,7 @@ "source": [ "## 8. Specifying with slopes — `Slopes`\n", "\n", - "When you know marginal values (slopes) rather than absolute breakpoint y-values, wrap them in `linopy.Slopes`. The x grid is **borrowed from the sibling tuple** in the call — no need to repeat it. The example below reproduces the same gas-turbine fit as section 1, but specified via per-piece slopes plus an initial y-value:" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "A `Slopes(...)` instance carries the spec; non-default ``align`` and ``dim`` are shown in its repr, defaults are hidden:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "linopy.Slopes([1.2, 1.6, 2.15], y0=0)" + "When marginal costs (slopes) are more natural than absolute y-values, wrap them in `linopy.Slopes`. The x grid is borrowed from the sibling tuple — no need to repeat it. Same curve as section 1:" ] }, { @@ -427,7 +411,6 @@ "power = m.add_variables(name=\"power\", lower=0, upper=100, coords=[time])\n", "fuel = m.add_variables(name=\"fuel\", lower=0, coords=[time])\n", "\n", - "# Same curve as section 1 — slopes 1.2, 1.6, 2.15 over x = [0, 30, 60, 100].\n", "m.add_piecewise_formulation(\n", " (power, [0, 30, 60, 100]),\n", " (fuel, linopy.Slopes([1.2, 1.6, 2.15], y0=0)),\n", @@ -438,22 +421,6 @@ "\n", "m.solution[[\"power\", \"fuel\"]].to_pandas()" ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For inspection or use outside `add_piecewise_formulation`, resolve a `Slopes` to a regular breakpoint `DataArray` by giving it an explicit x grid:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "linopy.Slopes([1.2, 1.6, 2.15], y0=0).to_breakpoints([0, 30, 60, 100])" - ] } ], "metadata": { From 53382521d7fd451b84a04b41c25b77bb29dde0cd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 6 May 2026 16:17:12 +0200 Subject: [PATCH 15/20] fix(piecewise): require exactly one non-Slopes tuple in add_piecewise_formulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous "borrow x grid from the first non-Slopes tuple" rule was silently order-dependent when more than one non-Slopes tuple was present. Each non-Slopes tuple is a y-vector for its own variable, so there is no canonical x axis — picking the *first* meant tuple order changed the resolved breakpoints, and therefore the optimisation problem itself. Reject the ambiguous case at the dispatch boundary instead. The new ValueError points users at ``Slopes(...).to_breakpoints(x_pts)`` so they can opt into a specific x grid explicitly when their setup has multiple breakpoint vectors in play. * ``Slopes`` docstring updated: states the "exactly one non-Slopes" rule and the ``to_breakpoints`` escape hatch up front. * ``test_three_tuple_deferred`` removed — its (power, fuel, Slopes) shape is now invalid and the equivalent (power, Slopes, Slopes) is already covered by ``test_multiple_slopes_share_x_grid``. * ``test_two_non_slopes_picks_first_x_grid`` → ``test_multiple_non_slopes_with_slopes_raises``: the test that previously pinned the order-dependent behaviour now pins the ValueError. Co-Authored-By: Claude Opus 4.7 (1M context) --- linopy/piecewise.py | 34 ++++++++++++------- test/test_piecewise_constraints.py | 53 ++++++++---------------------- 2 files changed, 36 insertions(+), 51 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index d17b576c..a879e6c0 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -109,17 +109,22 @@ class Slopes: Per-piece slopes + initial y-value, deferred until an x grid is known. Used as the second element of a tuple in - :func:`add_piecewise_formulation` where exactly one *other* tuple - provides the x grid for all :class:`Slopes` tuples in the call:: + :func:`add_piecewise_formulation`. When any :class:`Slopes` tuple is + present, **exactly one** other tuple must carry explicit breakpoints — + that tuple's values are the x grid against which all :class:`Slopes` + are integrated:: m.add_piecewise_formulation( - (power, [0, 30, 60, 100]), # provides x grid - (fuel, Slopes([1.2, 1.4, 1.7], y0=0)), # x grid borrowed + (power, [0, 30, 60, 100]), # the x grid + (fuel, Slopes([1.2, 1.4, 1.7], y0=0)), # integrated against power ) - Standalone use is possible via :meth:`to_breakpoints`, which resolves - the spec to an ordinary breakpoint :class:`xarray.DataArray` given an - explicit x grid. + With two or more non-:class:`Slopes` tuples there is no canonical x + axis, and the call raises :class:`ValueError`. Resolve the + :class:`Slopes` explicitly via :meth:`to_breakpoints` in that case, + or for any standalone use:: + + bp = Slopes([1.2, 1.4, 1.7], y0=0).to_breakpoints([0, 30, 60, 100]) Parameters ---------- @@ -1187,10 +1192,6 @@ def add_piecewise_formulation( ) parsed.append((expr, bp, tuple_sign)) - # Resolve any deferred Slopes tuples by borrowing the x grid from the - # first non-Slopes tuple. All non-Slopes tuples share the same - # BREAKPOINT_DIM (validated downstream), so picking the first is - # unambiguous. slopes_set = {i for i, p in enumerate(parsed) if isinstance(p[1], Slopes)} if slopes_set: non_slopes_idx = [i for i in range(len(parsed)) if i not in slopes_set] @@ -1200,6 +1201,17 @@ def add_piecewise_formulation( "explicit x grid. Pass the x grid via a regular tuple " "or call Slopes(...).to_breakpoints(x_pts) explicitly." ) + if len(non_slopes_idx) > 1: + raise ValueError( + f"Slopes tuples present at positions {sorted(slopes_set)}, " + f"but {len(non_slopes_idx)} non-Slopes tuples carry their " + f"own breakpoint values (positions {non_slopes_idx}). " + "There is no canonical x grid for the Slopes to integrate " + "against — borrowing from any one of them would silently " + "depend on tuple order. Either reduce to a single non-Slopes " + "tuple, or resolve the Slopes explicitly by calling " + "Slopes(...).to_breakpoints(x_pts) before passing it in." + ) x_grid = parsed[non_slopes_idx[0]][1] parsed = [ (expr, bp.to_breakpoints(x_grid), sign) diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 09204295..82598054 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -643,22 +643,6 @@ def test_two_tuple_deferred(self) -> None: assert f.method in ("sos2", "incremental") assert f.convexity == "concave" - def test_three_tuple_deferred(self) -> None: - """Slopes pulls x grid even with another non-Slopes tuple present.""" - from linopy import Slopes - - m = Model() - power = m.add_variables(name="power") - fuel = m.add_variables(name="fuel") - heat = m.add_variables(name="heat") - f = m.add_piecewise_formulation( - (power, [0, 30, 60, 100]), - (fuel, [0, 40, 85, 160]), - (heat, Slopes([0.8, 1.0, 1.0], y0=0)), - ) - # 3-var formulation -> convexity is None - assert f.convexity is None - def test_slopes_as_bounded_tuple(self) -> None: from linopy import Slopes @@ -684,35 +668,24 @@ def test_all_slopes_raises(self) -> None: (y, Slopes([1, 1], y0=0)), ) - @pytest.mark.skipif(not _any_solvers, reason="no solver available") - def test_two_non_slopes_picks_first_x_grid(self) -> None: + def test_multiple_non_slopes_with_slopes_raises(self) -> None: """ - With multiple non-Slopes tuples, the Slopes resolution must borrow - the x grid from the *first* non-Slopes tuple (deterministic). - - Pin this with distinguishable grids: x is [0, 10, 20, 30] and y - is [0, 100, 200, 300] (10× larger). Slopes ``[1, 1, 1]`` with - ``y0=0`` resolves to ``[0, 10, 20, 30]`` if borrowed from x and - ``[0, 100, 200, 300]`` if borrowed from y. Forcing the model - onto piece 1 (x == 10, hence y == 100) and solving must yield - z == 10 (matching the *first* — x — grid). + With Slopes present, two or more non-Slopes tuples is rejected: + each non-Slopes tuple is a y-vector for its own variable, so + there is no canonical x grid for the Slopes to integrate against. """ from linopy import Slopes m = Model() - x = m.add_variables(lower=0, upper=30, name="x") - y = m.add_variables(lower=0, upper=300, name="y") - z = m.add_variables(lower=0, upper=300, name="z") - m.add_piecewise_formulation( - (x, [0, 10, 20, 30]), # first non-Slopes — should be the source - (y, [0, 100, 200, 300]), # second non-Slopes — must NOT be picked - (z, Slopes([1, 1, 1], y0=0)), - ) - m.add_constraints(x == 10) - m.add_objective(z) # any feasible objective; equality pins z anyway - m.solve() - assert float(m.solution["z"]) == pytest.approx(10.0, abs=TOL) - assert float(m.solution["y"]) == pytest.approx(100.0, abs=TOL) + x = m.add_variables(name="x") + y = m.add_variables(name="y") + z = m.add_variables(name="z") + with pytest.raises(ValueError, match="no canonical x grid"): + m.add_piecewise_formulation( + (x, [0, 10, 20, 30]), + (y, [0, 100, 200, 300]), + (z, Slopes([1, 1, 1], y0=0)), + ) def test_multiple_slopes_share_x_grid(self) -> None: """ From 196e5322e22ac479e4d52772228897abb6030ce1 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 7 May 2026 10:41:47 +0200 Subject: [PATCH 16/20] test(piecewise): pin Slopes dispatch via assert_model_equal; widen ndarray/Real annotations --- linopy/piecewise.py | 14 +++-- test/test_piecewise_constraints.py | 87 ++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index a879e6c0..58b88626 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -86,12 +86,18 @@ def _warn_evolving_api(key: _EvolvingApiKey, message: str, stacklevel: int = 3) # Accepted input types for breakpoint-like data BreaksLike: TypeAlias = ( - Sequence[float] | DataArray | pd.Series | pd.DataFrame | dict[str, Sequence[float]] + Sequence[float] + | np.ndarray + | DataArray + | pd.Series + | pd.DataFrame + | dict[str, Sequence[float]] ) # Accepted input types for segment-like data (2D: segments × breakpoints) SegmentsLike: TypeAlias = ( Sequence[Sequence[float]] + | np.ndarray | DataArray | pd.DataFrame | dict[str, Sequence[Sequence[float]]] @@ -158,7 +164,7 @@ class Slopes: """ values: BreaksLike - y0: float | dict[str, float] | pd.Series | DataArray = 0.0 + y0: Real | dict[str, Real] | pd.Series | DataArray = 0.0 align: Literal["pieces", "leading"] = "pieces" dim: str | None = None @@ -469,7 +475,7 @@ def _rename_to_pieces(da: DataArray, piece_index: np.ndarray) -> DataArray: return da -def _sequence_to_array(values: Sequence[float]) -> DataArray: +def _sequence_to_array(values: Sequence[float] | np.ndarray | pd.Series) -> DataArray: arr = np.asarray(values, dtype=float) if arr.ndim != 1: raise ValueError( @@ -564,7 +570,7 @@ def _dict_segments_to_array( def _breakpoints_from_slopes( slopes: BreaksLike, x_points: BreaksLike, - y0: float | dict[str, float] | pd.Series | DataArray, + y0: Real | dict[str, Real] | pd.Series | DataArray, dim: str | None, slopes_align: Literal["pieces", "leading"] = "pieces", ) -> DataArray: diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 82598054..04c0f440 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -723,6 +723,93 @@ def test_slopes_align_leading_in_dispatch(self) -> None: assert f.convexity == "concave" +class TestSlopesDispatchEquivalence: + """ + Deferred Slopes dispatch builds the same model as eager breakpoints. + + The wiring tests in :class:`TestSlopesDispatch` verify dispatch attributes + (``method``/``convexity``). These tests pin the *outcome*: the deferred + form must produce a model byte-equal to the eagerly-resolved reference + (same auxiliary variables, same constraint coefficients/RHS). + """ + + def test_two_tuple_matches_eager(self) -> None: + from linopy import Slopes + + from linopy.testing import assert_model_equal + + # Slopes([1.2, 1.4, 1.7], y0=0) over [0, 30, 60, 100] resolves to + # fuel breakpoints [0, 36, 78, 146]. + m_eager = Model() + p1 = m_eager.add_variables(lower=0, upper=100, name="power") + f1 = m_eager.add_variables(lower=0, name="fuel") + m_eager.add_piecewise_formulation( + (p1, [0, 30, 60, 100]), (f1, [0, 36, 78, 146]) + ) + + m_deferred = Model() + p2 = m_deferred.add_variables(lower=0, upper=100, name="power") + f2 = m_deferred.add_variables(lower=0, name="fuel") + m_deferred.add_piecewise_formulation( + (p2, [0, 30, 60, 100]), + (f2, Slopes([1.2, 1.4, 1.7], y0=0)), + ) + + assert_model_equal(m_eager, m_deferred) + + def test_multiple_slopes_resolved_breakpoints(self) -> None: + """ + Two Slopes tuples resolve against the same borrowed x grid: + y → [0, 10, 20, 30], z → [0, 20, 40, 60]. + """ + from linopy import Slopes + + from linopy.testing import assert_model_equal + + m_eager = Model() + x1 = m_eager.add_variables(lower=0, upper=30, name="x") + y1 = m_eager.add_variables(lower=0, name="y") + z1 = m_eager.add_variables(lower=0, name="z") + m_eager.add_piecewise_formulation( + (x1, [0, 10, 20, 30]), + (y1, [0, 10, 20, 30]), + (z1, [0, 20, 40, 60]), + ) + + m_deferred = Model() + x2 = m_deferred.add_variables(lower=0, upper=30, name="x") + y2 = m_deferred.add_variables(lower=0, name="y") + z2 = m_deferred.add_variables(lower=0, name="z") + m_deferred.add_piecewise_formulation( + (x2, [0, 10, 20, 30]), + (y2, Slopes([1, 1, 1], y0=0)), + (z2, Slopes([2, 2, 2], y0=0)), + ) + + assert_model_equal(m_eager, m_deferred) + + def test_align_leading_matches_eager(self) -> None: + """``align='leading'`` dispatch resolves to bps [0, 1, 3].""" + from linopy import Slopes + + from linopy.testing import assert_model_equal + + m_eager = Model() + x1 = m_eager.add_variables(lower=0, upper=2, name="x") + y1 = m_eager.add_variables(name="y") + m_eager.add_piecewise_formulation((x1, [0, 1, 2]), (y1, [0, 1, 3])) + + m_deferred = Model() + x2 = m_deferred.add_variables(lower=0, upper=2, name="x") + y2 = m_deferred.add_variables(name="y") + m_deferred.add_piecewise_formulation( + (x2, [0, 1, 2]), + (y2, Slopes([np.nan, 1, 2], y0=0, align="leading")), + ) + + assert_model_equal(m_eager, m_deferred) + + # =========================================================================== # segments() factory # =========================================================================== From 4bfee2ffa047ec5a27191015807818d75602a878 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 08:41:58 +0000 Subject: [PATCH 17/20] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- test/test_piecewise_constraints.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/test_piecewise_constraints.py b/test/test_piecewise_constraints.py index 04c0f440..987336a4 100644 --- a/test/test_piecewise_constraints.py +++ b/test/test_piecewise_constraints.py @@ -735,7 +735,6 @@ class TestSlopesDispatchEquivalence: def test_two_tuple_matches_eager(self) -> None: from linopy import Slopes - from linopy.testing import assert_model_equal # Slopes([1.2, 1.4, 1.7], y0=0) over [0, 30, 60, 100] resolves to @@ -763,7 +762,6 @@ def test_multiple_slopes_resolved_breakpoints(self) -> None: y → [0, 10, 20, 30], z → [0, 20, 40, 60]. """ from linopy import Slopes - from linopy.testing import assert_model_equal m_eager = Model() @@ -791,7 +789,6 @@ def test_multiple_slopes_resolved_breakpoints(self) -> None: def test_align_leading_matches_eager(self) -> None: """``align='leading'`` dispatch resolves to bps [0, 1, 3].""" from linopy import Slopes - from linopy.testing import assert_model_equal m_eager = Model() From cc851858d0c1a6844c941aa8de5014aac4aba377 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 7 May 2026 10:46:19 +0200 Subject: [PATCH 18/20] refactor(piecewise): trim _values_equal and _summarise_breakslike --- linopy/piecewise.py | 65 ++++++++++++++++++--------------------------- 1 file changed, 26 insertions(+), 39 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 58b88626..09250ec3 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -244,29 +244,25 @@ def __eq__(self, other: object) -> bool: __hash__ = None # type: ignore[assignment] +def _is_numeric_scalar(x: object) -> bool: + return isinstance(x, Real) and not isinstance(x, bool) + + def _values_equal(a: object, b: object) -> bool: """ Type-dispatched equality for ``Slopes`` field values (NaN-safe). - Numeric scalars compare by value (``int 0 == float 0.0 == np.float64(0)``) - with a NaN-safe fallback. Lists / tuples are promoted to ndarray so - in-place ``float('nan')`` content compares element-wise NaN-safe rather - than relying on the ``np.nan`` singleton. Non-numeric ndarray dtypes - (object, string) fall back to a plain ``np.array_equal`` without - ``equal_nan``. + Numeric scalars compare by value across types (``int 0 == float 0.0 == + np.float64(0)``); ``bool`` is excluded. Lists / tuples are promoted + to ndarray so in-place ``float('nan')`` content compares NaN-safe. + Non-numeric ndarray dtypes fall back to ``np.array_equal`` without + ``equal_nan``. ``DataFrame`` / ``Series`` / ``DataArray`` use + ``.equals``; ``dict`` recurses on matching keys. """ - # Numeric scalars (int/float/np.integer/np.floating) — coerce and compare. - # ``bool`` is a subclass of ``int``; exclude so True/False compare strictly. - if ( - isinstance(a, Real) - and not isinstance(a, bool) - and isinstance(b, Real) - and not isinstance(b, bool) - ): + if _is_numeric_scalar(a) and _is_numeric_scalar(b): af, bf = float(a), float(b) - return (af != af and bf != bf) or af == bf + return af == bf or (af != af and bf != bf) - # Promote list/tuple so the ndarray path handles NaN content uniformly. if isinstance(a, list | tuple): a = np.asarray(a) if isinstance(b, list | tuple): @@ -278,21 +274,20 @@ def _values_equal(a: object, b: object) -> bool: try: return bool(np.array_equal(a, b, equal_nan=True)) except TypeError: - # Object / string dtype: ``equal_nan`` raises on isnan. return bool(np.array_equal(a, b)) - if isinstance(a, pd.DataFrame): - return isinstance(b, pd.DataFrame) and bool(a.equals(b)) - if isinstance(a, pd.Series): - return isinstance(b, pd.Series) and bool(a.equals(b)) - if isinstance(a, DataArray): - return isinstance(b, DataArray) and bool(a.equals(b)) + + for cls in (pd.DataFrame, pd.Series, DataArray): + if isinstance(a, cls): + return isinstance(b, cls) and bool(a.equals(b)) + if isinstance(a, dict): - if not isinstance(b, dict) or a.keys() != b.keys(): - return False - return all(_values_equal(a[k], b[k]) for k in a) - if type(a) is not type(b): - return False - return a == b + return ( + isinstance(b, dict) + and a.keys() == b.keys() + and all(_values_equal(a[k], b[k]) for k in a) + ) + + return type(a) is type(b) and bool(a == b) def _summarise_breakslike(v: BreaksLike) -> str: @@ -306,19 +301,11 @@ def _summarise_breakslike(v: BreaksLike) -> str: return f"" if isinstance(v, dict): return f"" - # Multi-dim ndarray: shape summary (consistent with DataArray / - # DataFrame / Series treatment above). ``np.asarray`` is called once - # so we don't double-normalise on the 1D path below. + arr = np.asarray(v) if arr.ndim > 1: return f"" - # 1D path: render inline up to 8 entries (shortest float format, - # ``g``); longer truncates to head + tail with item count. - # ``arr.tolist()`` normalises numpy scalars (e.g. ``np.float64`` / - # ``np.int64``) to Python types so the rendering is uniform. - seq = arr.tolist() - if not isinstance(seq, list): # 0-D ndarray → scalar - return _short_num(seq) + seq: list = arr.tolist() if len(seq) <= 8: return "[" + ", ".join(_short_num(x) for x in seq) + "]" head = ", ".join(_short_num(x) for x in seq[:3]) From c208ddd99a889f1b14dbe4ff0faf86dab168a939 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 7 May 2026 10:51:13 +0200 Subject: [PATCH 19/20] fix(piecewise): TypeGuard on _is_numeric_scalar for mypy --- linopy/piecewise.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 09250ec3..3b207d09 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -12,7 +12,7 @@ from collections.abc import Sequence from dataclasses import dataclass from numbers import Real -from typing import TYPE_CHECKING, Literal, TypeAlias +from typing import TYPE_CHECKING, Literal, TypeAlias, TypeGuard import numpy as np import pandas as pd @@ -244,7 +244,7 @@ def __eq__(self, other: object) -> bool: __hash__ = None # type: ignore[assignment] -def _is_numeric_scalar(x: object) -> bool: +def _is_numeric_scalar(x: object) -> TypeGuard[Real]: return isinstance(x, Real) and not isinstance(x, bool) @@ -276,9 +276,8 @@ def _values_equal(a: object, b: object) -> bool: except TypeError: return bool(np.array_equal(a, b)) - for cls in (pd.DataFrame, pd.Series, DataArray): - if isinstance(a, cls): - return isinstance(b, cls) and bool(a.equals(b)) + if isinstance(a, pd.DataFrame | pd.Series | DataArray): + return isinstance(b, type(a)) and bool(a.equals(b)) # type: ignore[arg-type] if isinstance(a, dict): return ( From fbf37709e8a7048bbd955d4dfb8f83c9f7738e74 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 7 May 2026 11:03:07 +0200 Subject: [PATCH 20/20] fix(piecewise): revert _values_equal equals-loop to explicit branches for mypy --- linopy/piecewise.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 3b207d09..7497c4bf 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -276,8 +276,12 @@ def _values_equal(a: object, b: object) -> bool: except TypeError: return bool(np.array_equal(a, b)) - if isinstance(a, pd.DataFrame | pd.Series | DataArray): - return isinstance(b, type(a)) and bool(a.equals(b)) # type: ignore[arg-type] + if isinstance(a, pd.DataFrame): + return isinstance(b, pd.DataFrame) and bool(a.equals(b)) + if isinstance(a, pd.Series): + return isinstance(b, pd.Series) and bool(a.equals(b)) + if isinstance(a, DataArray): + return isinstance(b, DataArray) and bool(a.equals(b)) if isinstance(a, dict): return (