Skip to content

Feature request: SOS constraints and piecewise linear functions #225

@FBumann

Description

@FBumann

Feature Request: Piecewise Linear Constraints (with SOS2 support)

Motivation

Piecewise linear (PWL) constraints are essential for modeling nonlinear relationships in linear/mixed-integer programs — e.g., cost curves, efficiency curves, fuel consumption, or any function that can be approximated by line segments.

The core use case is linking one or more variables through a piecewise linear relationship: given breakpoints that define a nonlinear curve, constrain variables to lie on that curve. For example, linking a generator's power output to its fuel cost, or linking flow to pressure drop.

Currently, pyoframe has no support for piecewise linear constraints. The underlying solver interface (PyOptInterface) already provides model.add_sos_constraint() for SOS2 constraints, so backend support exists — it just needs to be exposed through pyoframe's API.

Background

Piecewise Linear Functions

A piecewise linear function approximates a nonlinear function $f(x)$ using breakpoints $(x_0, x_1, \ldots, x_n)$ and corresponding function values $(f(x_0), f(x_1), \ldots, f(x_n))$. Between breakpoints, the function is linearly interpolated.

The standard convex combination (SOS2) formulation introduces auxiliary $\lambda$ variables:

  • $x = \sum_i \lambda_i \cdot x_i$ (input linking)
  • $y = \sum_i \lambda_i \cdot f(x_i)$ (output linking)
  • $\sum_i \lambda_i = 1$ (convexity)
  • $\lambda$ form an SOS2 set (adjacency — at most two adjacent $\lambda_i$ non-zero)

SOS Constraints

  • SOS1 (Type 1): At most one variable in the set can be non-zero.
  • SOS2 (Type 2): At most two variables can be non-zero, and they must be adjacent in the ordered set. This is the building block for piecewise linear interpolation.

Proposed API

The primary interface should focus on linking variables through piecewise linear relationships:

import pyoframe as pf
import polars as pl

m = pf.Model()

# --- Scalar example ---
m.x = pf.Variable(lb=0, ub=10)

breakpoints_x = [0, 2, 5, 8, 10]
breakpoints_y = [0, 3, 4, 6, 10]

# Link x and y through a piecewise linear curve
m.pwl = pf.PiecewiseLinear(m.x, m.y, breakpoints_x, breakpoints_y)

# --- Indexed example (e.g., different cost curves per generator) ---
m.power = pf.Variable({"gen": ["g1", "g2"]}, lb=0)
m.cost = pf.Variable({"gen": ["g1", "g2"]}, lb=0)

# Breakpoints can be a DataFrame with different curves per index
breakpoints = pl.DataFrame({
    "gen": ["g1", "g1", "g1", "g2", "g2", "g2"],
    "bp":  [   0,   50, 100,    0,   80, 150],
    "power_bp": [0, 50, 100, 0, 80, 150],
    "cost_bp":  [0, 30,  80, 0, 50, 120],
})

m.cost_curve = pf.PiecewiseLinear(
    {"power": m.power, "cost": m.cost},
    breakpoints,
)

m.minimize = m.cost.sum()

Multi-variable linking

A key capability (as in linopy's design) is linking multiple variables simultaneously through shared interpolation weights. This is essential when a single underlying parameter (e.g., operating point) determines multiple outputs (e.g., both cost and emissions).

# Shared lambda weights link power, cost, AND emissions together
m.curves = pf.PiecewiseLinear(
    {"power": m.power, "cost": m.cost, "emissions": m.emissions},
    breakpoints,  # contains columns for each linked variable
)

Formulation Methods

Different formulation methods suit different problem structures (cf. linopy PR #576):

Method Description Requirements Variables
SOS2 (convex combination) General PWL using SOS2 Any breakpoint order $\lambda$ continuous + SOS2
Incremental (delta) Pure LP formulation Strictly monotonic breakpoints $\delta$ continuous only
Disjunctive Disconnected segments Forbidden zones / gaps Binary + $\lambda$ per segment

The incremental method is particularly attractive because it requires no SOS2 or binary variables — it's a pure LP formulation that works when breakpoints are monotonic. An method="auto" option could auto-select based on breakpoint structure.

Low-level SOS Support

While the main goal is the piecewise linear API, exposing raw SOS1/SOS2 constraints is also useful for advanced users:

# SOS2: at most two adjacent x[i] are non-zero
m.sos2 = pf.SOS2(m.x, weights=[1, 2, 3, 4, 5])

# SOS1: at most one x[i] is non-zero
m.sos1 = pf.SOS1(m.x)

Design considerations:

  • SOS constraints are fundamentally different from linear/quadratic constraints (no LHS/RHS/sense) — they may need a distinct class.
  • Solver support: Gurobi and COPT support SOS natively; HiGHS has limited support; Ipopt does not.

Implementation Notes

  • PyOptInterface already supports model.add_sos_constraint(variables, SOSType, weights).
  • A supports_sos capability flag should be added to the _Solver dataclass in _constants.py.
  • The incremental formulation could be implemented without any new solver features (pure LP), making it available on all solvers including HiGHS.
  • Dimensioned piecewise linear constraints need careful design — breakpoints may vary per index (e.g., different cost curves per generator).

Reference

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions