Skip to content

Piecewise linear constraints: follow-up improvements#602

Merged
FabianHofmann merged 12 commits intomasterfrom
piecewise-followup
Mar 9, 2026
Merged

Piecewise linear constraints: follow-up improvements#602
FabianHofmann merged 12 commits intomasterfrom
piecewise-followup

Conversation

@FabianHofmann
Copy link
Copy Markdown
Collaborator

@FabianHofmann FabianHofmann commented Mar 6, 2026

Changes proposed in this Pull Request

Strongly needed follow-up improvements to the piecewise linear constraints feature:

  • Introduce piecewise function to create a PiecewiseExpression which can be used in operations like y == piecewise(x, x_pts, y_pts)
  • Make use case breakpoints and segments (new) clearer.
  • Apply convexity checks where needed (LP formulation)
  • Documentation: Expanded doc/piecewise-linear-constraints.rst and updated notebook

Checklist

  • Code changes are sufficiently documented; i.e. new functions contain docstrings and further explanations may be given in doc.
  • Unit tests for new features were added (if applicable).
  • A note for the release notes doc/release_notes.rst of the upcoming release is included.
  • I consent to the release of this PR's code under the MIT license.

…ts API, LP formulation for convex/concave cases, and simplify tests
…l formulation, add domain bounds to LP formulation

- Incremental method now uses binary indicator variables with link/order constraints to enforce proper segment filling order (Markowitz & Manne)
- LP method now adds x ∈ [min(xᵢ), max(xᵢ)] domain bound constraints to prevent extrapolation beyond breakpoints
Validate trailing-NaN-only for SOS2 and disjunctive methods to prevent
corrupted adjacency. Fail fast when skip_nan_check=True but breakpoints
actually contain NaN.
Support reversed syntax (y == piecewise(...)) via __le__/__ge__/__eq__
dispatch in BaseExpression and ScalarLinearExpression. Fix LP example
to use power == demand for more illustrative results.
- Add @overload to comparison operators (__le__, __ge__, __eq__) in
  BaseExpression and Variable to distinguish PiecewiseExpression from
  SideLike return types
- Update ConstraintLike type alias to include PiecewiseConstraintDescriptor
- Fix PiecewiseConstraintDescriptor.lhs type from object to LinExprLike
- Fix dict/sequence type mismatches in _dict_to_array, _dict_segments_to_array,
  _segments_list_to_array
- Remove unused type: ignore comments
- Narrow ScalarLinearExpression/ScalarVariable return types to not include
  PiecewiseConstraintDescriptor (impossible at runtime)
@FabianHofmann FabianHofmann requested a review from coroa March 6, 2026 12:18
FBumann and others added 2 commits March 9, 2026 13:19
* feat: add `active` parameter to piecewise linear constraints

Add an `active` parameter to the `piecewise()` function that accepts a
binary variable to gate piecewise linear functions on/off. This enables
unit commitment formulations where a commitment binary controls the
operating range.

The parameter modifies each formulation method as follows:
- Incremental: δ_i ≤ active (tightened bounds) + base terms × active
- SOS2: Σλ_i = active (instead of 1)
- Disjunctive: Σz_k = active (instead of 1)

When active=0, all auxiliary variables are forced to zero, collapsing
x and y to zero. When active=1, the normal PWL domain is active.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: tighten active parameter docstrings

Clarify that zero-forcing is the only linear formulation possible —
relaxing the constraint would require big-M or indicator constraints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add active parameter to release notes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve mypy type errors for x_base/y_base assignment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add unit commitment example to piecewise notebook

Example 6 demonstrates the active parameter with a gas unit that
stays off at t=1 (low demand) and commits at t=2,3 (high demand),
showing power=0 and fuel=0 when the commitment binary is off.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update notebook

* test: comprehensive active parameter test coverage

Add tests for gaps identified in review:
- Inequality + active (incremental and SOS2, on and off)
- auto method selection + active (equality and auto-LP rejection)
- active with LinearExpression (not just Variable)
- active with NaN-masked breakpoints
- LP file output comparison (active vs plain)
- Multi-dimensional solver test (per-entity on/off)
- SOS2 non-zero base + active off
- SOS2 inequality + active off
- Disjunctive active on (solver)
- Fix: reject active when auto resolves to LP

159 tests pass (was 122).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract PWL_ACTIVE_BOUND_SUFFIX constant

Move the active bound constraint name suffix to constants.py,
consistent with all other PWL suffix constants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: remove redundant active parameter tests

Keep only tests that exercise unique code paths or verify distinct
mathematical properties.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
@FabianHofmann FabianHofmann merged commit 982b573 into master Mar 9, 2026
2 of 4 checks passed
@FabianHofmann FabianHofmann deleted the piecewise-followup branch March 9, 2026 12:19
@coroa
Copy link
Copy Markdown
Member

coroa commented Mar 9, 2026

I'd have liked to review this: I still have questions about the use of breakpoints for x and y or why they are used at all. I would prefer we remove add_piecewise_constraints and merge its code into add_constraints.

I don't understand the docs in regards to inequalities. They are slightly misleading, it's probably easiest to discuss once more

@FabianHofmann
Copy link
Copy Markdown
Collaborator Author

FabianHofmann commented Mar 10, 2026

Hey @coroa, please do the review anyway. This will likely need another follow up. I mostly wanted to avoid the misleading state in the master branch and resolve that asap. and as it is a bit more sorted now, there is no need to pressure you on the review (I know that you are on a workshop this week).

FabianHofmann added a commit to CharlieFModo/linopy that referenced this pull request Mar 12, 2026
* Refactor piecewise constraints: add piecewise/segments/slopes_to_points API, LP formulation for convex/concave cases, and simplify tests

* piecewise: replace bp_dim/seg_dim params with constants, remove dead code, improve errors

* Fix piecewise linear constraints: add binary indicators to incremental formulation, add domain bounds to LP formulation

- Incremental method now uses binary indicator variables with link/order constraints to enforce proper segment filling order (Markowitz & Manne)
- LP method now adds x ∈ [min(xᵢ), max(xᵢ)] domain bound constraints to prevent extrapolation beyond breakpoints

* update signatures of breakpoints and segments, apply convexity check only where needed

* update doc

* Reject interior NaN and skip_nan_check+NaN in piecewise formulations

Validate trailing-NaN-only for SOS2 and disjunctive methods to prevent
corrupted adjacency. Fail fast when skip_nan_check=True but breakpoints
actually contain NaN.

* Allow piecewise() on either side of comparison operators

Support reversed syntax (y == piecewise(...)) via __le__/__ge__/__eq__
dispatch in BaseExpression and ScalarLinearExpression. Fix LP example
to use power == demand for more illustrative results.

* Fix mypy type errors for piecewise constraint types

- Add @overload to comparison operators (__le__, __ge__, __eq__) in
  BaseExpression and Variable to distinguish PiecewiseExpression from
  SideLike return types
- Update ConstraintLike type alias to include PiecewiseConstraintDescriptor
- Fix PiecewiseConstraintDescriptor.lhs type from object to LinExprLike
- Fix dict/sequence type mismatches in _dict_to_array, _dict_segments_to_array,
  _segments_list_to_array
- Remove unused type: ignore comments
- Narrow ScalarLinearExpression/ScalarVariable return types to not include
  PiecewiseConstraintDescriptor (impossible at runtime)

* rename header of jupyter notebook

* doc: rename notebook again

* feat: add active parameter to piecewise linear constraints (PyPSA#604)

* feat: add `active` parameter to piecewise linear constraints

Add an `active` parameter to the `piecewise()` function that accepts a
binary variable to gate piecewise linear functions on/off. This enables
unit commitment formulations where a commitment binary controls the
operating range.

The parameter modifies each formulation method as follows:
- Incremental: δ_i ≤ active (tightened bounds) + base terms × active
- SOS2: Σλ_i = active (instead of 1)
- Disjunctive: Σz_k = active (instead of 1)

When active=0, all auxiliary variables are forced to zero, collapsing
x and y to zero. When active=1, the normal PWL domain is active.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: tighten active parameter docstrings

Clarify that zero-forcing is the only linear formulation possible —
relaxing the constraint would require big-M or indicator constraints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add active parameter to release notes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve mypy type errors for x_base/y_base assignment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add unit commitment example to piecewise notebook

Example 6 demonstrates the active parameter with a gas unit that
stays off at t=1 (low demand) and commits at t=2,3 (high demand),
showing power=0 and fuel=0 when the commitment binary is off.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update notebook

* test: comprehensive active parameter test coverage

Add tests for gaps identified in review:
- Inequality + active (incremental and SOS2, on and off)
- auto method selection + active (equality and auto-LP rejection)
- active with LinearExpression (not just Variable)
- active with NaN-masked breakpoints
- LP file output comparison (active vs plain)
- Multi-dimensional solver test (per-entity on/off)
- SOS2 non-zero base + active off
- SOS2 inequality + active off
- Disjunctive active on (solver)
- Fix: reject active when auto resolves to LP

159 tests pass (was 122).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract PWL_ACTIVE_BOUND_SUFFIX constant

Move the active bound constraint name suffix to constants.py,
consistent with all other PWL suffix constants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: remove redundant active parameter tests

Keep only tests that exercise unique code paths or verify distinct
mathematical properties.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: FBumann <117816358+FBumann@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
@FBumann
Copy link
Copy Markdown
Collaborator

FBumann commented Mar 30, 2026

@FabianHofmann This PR improved the code quality pf the feature a lot i think.
But it also made some simplifications that are a real deal breaker for me. I think your intend with this follow up was to make the case of linking 2 variables via piecewise simpler, but this came at the cost of less universal usage and other potential issues.

  1. The formulation now only allows to link exactly 2 variables. This is a fundamental limitation and will prevent users (including me) from using the api. I often need to link 1,2,3 or more variables (like a chp plant with fuel in, heat and power out = 3 vars with nonlinear efficiencies)
  2. This API hides the complexity of piecewise constraints. the constraint is now captured as a new class/state and abstraction layer. This complicates inspection and interoperability with regular expressions and constraints. Also, this goes against the principle of exposing only variables and constraints. I would rather have a construction layer which convers everything into regular expressions/constraints instead of a State/object. Like we had in Feat/add piecewise linear #558

A bit more Insight with examples:

What changed

PR #602 replaced the old dict[str, LinExprLike] + link_dim API with a simpler piecewise(expr, x_points, y_points) function that only supports 2-variable relationships (y = f(x)).

What was lost

The old API could link 3+ variables through shared SOS2 lambda weights:

# OLD API
m.add_piecewise_constraints(
    expr={"x": x_var, "y": y_var, "z": z_var},
    breakpoints=bp,  # link_dim matched dict keys
)

This is no longer possible. The new API is strictly x → y:

# NEW API
m.add_piecewise_constraints(piecewise(x, x_pts, y_pts) == y)

Workarounds

1. Chain piecewise constraints

Works when the relationship decomposes (e.g. z = g(x) + h(y)). Use two separate piecewise() calls and sum the results. Only applicable for separable functions.

2. Manual lambda formulation (essentially constructing the piecewise function yourselves)

For true multi-variable linking, reproduce the SOS2 lambda formulation manually. All required operations (Variable * DataArray, .sum(), add_sos_constraints) are supported:

import numpy as np
import pandas as pd

bp_idx = pd.Index(np.arange(n_breakpoints), name="breakpoint")

lam = m.add_variables(lower=0, upper=1, coords=[bp_idx], name="lambda")
m.add_constraints(lam.sum("breakpoint") == 1)          # convexity
m.add_constraints(x == (lam * x_pts).sum("breakpoint")) # link x
m.add_constraints(y == (lam * y_pts).sum("breakpoint")) # link y
m.add_constraints(z == (lam * z_pts).sum("breakpoint")) # link z
m.add_sos_constraints(lam, sos_type=2, sos_dim="breakpoint")  # adjacency

Summary

The current state is not usable for me. I would really like to bring back the more generic #558 approach.
I would keep the distinction between segments/piecewise and think about adding a simple constructur method for your x-->y case

What do you think about this?
@FabianHofmann @coroa

@FabianHofmann
Copy link
Copy Markdown
Collaborator Author

@FBumann this makes totally sense and this use case was not on my radar at all, sorry for this. there were so many new aspects to think about. so let's bring it back. can we find a compromise that allows the piecewise function to be applied on 2+ variables? or do you think we should directly go for the dict approach?

@FBumann
Copy link
Copy Markdown
Collaborator

FBumann commented Mar 30, 2026

@FabianHofmann I'm not entirely sure we need to choose between the two approaches. If we go for a construction layer (no dedicated Piecewise class/state), there's no issue with supporting different use cases through method overloading or shared internal helpers.

The key architectural decision is: should piecewise constraints introduce new state (PiecewiseConstraintDescriptor) or should they be a pure construction layer that produces regular Variables, Constraints, and Expressions?

I'd argue strongly for the construction layer, for a few reasons:

  1. API surface stays minimal. linopy's strength is that the entire API is just Model, Variables, Constraints, Expression. Adding PiecewiseExpression and PiecewiseConstraintDescriptor as user-facing types breaks this. Users now need to understand a new concept that doesn't exist in the optimization domain — it's an implementation artifact.

  2. No loss of functionality. Everything the descriptor does can be done by a single add_piecewise_constraints() call that directly creates the auxiliary variables, SOS constraints, and linking constraints. The descriptor is a detour, not a necessity.

  3. Multi-variable linking becomes natural. The current descriptor pattern is structurally tied to 2-variable x → y relationships. A construction function with a dict-based API (like pre-Piecewise linear constraints: follow-up improvements #602) supports N-variable linking without any contortion — CHP plants with fuel/power/heat are a first-class use case, not a workaround.

  4. Interoperability. When piecewise constraints produce regular linopy objects, they compose with everything else — .dual, .to_matrix(), IO, inspection. A descriptor is opaque until it's expanded.

A single overloaded method could cover both the simple and the complex case:

# Simple 2-variable case
m.add_piecewise_constraints(power, fuel, x_pts, y_pts, sign="==")

# N-variable case (shared lambdas)
m.add_piecewise_constraints(
    exprs={"power": power, "fuel": fuel, "heat": heat},
    breakpoints=bp,
    sign="==",
)

Proposed first step: Agree that piecewise constraints should not introduce stored state (PiecewiseConstraintDescriptor, PiecewiseExpression). Once we align on that, we can list the use cases to cover and design the API signature together.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants