piecewise¶
Per-generator convex cost curves, expanded into a λ-formulation.
✔ Agrees with hand-written linopy 0.9.0 — objective 3850, matched to
rtol=1e-09.
The problem¶
Each generator gets its own breakpoint list, so the curve varies per unit, which a flat breakpoint list cannot express:
The model¶
The same model, as math
Least-cost dispatch where each generator's cost curve is piecewise-linear in its output, expanded into a lambda formulation.
Sets¶
| Symbol | Meaning |
|---|---|
| \(\mathcal{T}\) | index \(t\) — snapshot — dispatch periods |
| \(\mathcal{G}\) | index \(g\) — generator — dispatchable units |
| \(\mathcal{K}\) | index \(k\) — bp — breakpoints of the cost curve |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(\mathrm{p}^{\mathrm{max}}\) | p_max over \(\mathcal{G}\) — maximum dispatch |
| \(\mathrm{load}\) | load over \(\mathcal{T}\) — demand to be met |
| \(x\) | bp_x over \(\mathcal{G} \times \mathcal{K}\) — breakpoint dispatch levels, one curve per generator |
| \(y\) | bp_y over \(\mathcal{G} \times \mathcal{K}\) — cost at each breakpoint, one curve per generator |
Variables¶
| Symbol | Meaning |
|---|---|
| \(p\) | p over \(\mathcal{T} \times \mathcal{G}\) — dispatched power |
| \(\mathrm{cost}\) | op_cost over \(\mathcal{T} \times \mathcal{G}\) — operating cost, piecewise-linear in dispatch |
Upright is what the data supplies — a parameter such as \(\mathrm{p}^{\mathrm{max}}\), a coordinate map, a label — and italic is what the solver chooses, such as \(p\). An index is italic too, being what a quantifier chooses, and a set is script.
\(t \boxminus_{v} k\) denotes translation with \(v\) standing where index \(t-k\) leaves the dimension (shift(edge=v)), so the row at that boundary is built and carries \(v\) rather than being dropped.
\(\mathrm{pos}(t)\) denotes where index \(t\) sits along its dimension's own order — the order shift steps along, not the order labels sort in — counted from \(0\). The index itself stays the coordinate, so \(t\) compares against labels and \(\mathrm{pos}(t)\) against positions.
\(\lvert \mathcal{T} \rvert\) denotes the size of the set being counted along, and a position counted from the end prints against it — \(\lvert \mathcal{T} \rvert - 1\) is the last position, one less than the size because the first is \(0\).
Objective¶
Subject to¶
balance
cost_curve
Variable domains¶
p
op_cost
Assumptions¶
cost_curve_complete
cost_curve_increasing
cost_curve_curvature
The tabs start from the instance's tables — one frame per parameter.
description: >-
Least-cost dispatch where each generator's cost curve is piecewise-linear in
its output, expanded into a lambda formulation.
dimensions:
snapshot:
description: dispatch periods
dtype: int
generator:
description: dispatchable units
dtype: str
bp:
description: breakpoints of the cost curve
dtype: int
parameters:
p_max:
description: maximum dispatch
dims: [generator]
load:
description: demand to be met
dims: [snapshot]
bp_x:
description: breakpoint dispatch levels, one curve per generator
dims: [generator, bp]
bp_y:
description: cost at each breakpoint, one curve per generator
dims: [generator, bp]
variables:
p:
description: dispatched power
dims: [snapshot, generator]
bounds:
lower: 0
upper: p_max
op_cost:
description: operating cost, piecewise-linear in dispatch
dims: [snapshot, generator]
bounds:
lower: 0
piecewise:
cost_curve:
description: >-
cost read off the generator's curve — convex, so the weights need no
binaries to keep them on one segment
over: bp
links:
- [p, bp_x]
- [op_cost, bp_y]
method: convex
constraints:
balance:
dims: [snapshot]
expression: sum(p, over=generator) == load
objective:
sense: minimize
description: total operating cost, taken off the curves rather than from a marginal rate
expression: sum(op_cost)
The model-building half of examples/ports/references/linopy/piecewise.py:
def build(tables: dict[str, pd.DataFrame]) -> linopy.Model:
"""The instance's tables as a linopy model, row for row.
``tables`` is the same mapping the specsolve call attaches as ``sources``.
"""
p_max: pd.Series = tables['p_max'].set_index('generator')['value']
load: pd.Series = tables['load'].set_index('snapshot')['value']
curve_x: pd.DataFrame = tables['bp_x'].pivot(index='generator', columns='bp', values='value').reindex(p_max.index)
curve_y: pd.DataFrame = tables['bp_y'].pivot(index='generator', columns='bp', values='value').reindex(p_max.index)
bp_x = linopy.breakpoints(curve_x, dim='generator')
bp_y = linopy.breakpoints(curve_y, dim='generator')
m = linopy.Model()
p = m.add_variables(lower=0, upper=p_max, coords=[load.index, p_max.index], name='p')
op_cost = m.add_variables(lower=0, coords=[load.index, p_max.index], name='op_cost')
m.add_piecewise_formulation((p, bp_x), (op_cost, bp_y, '>='))
m.add_constraints(p.sum('generator') == load, name='balance')
m.add_objective(op_cost.sum())
return m
What it exercises¶
piecewise: is a declaration, not an operator. It expands into the
λ-formulation above before lowering, and nothing called piecewise survives
into the plan. With method: convex the expansion emits no binaries: the
convex hull is exact for a convex curve under minimisation, so the model stays
a pure LP. method: adjacency, the default, adds segment binaries and
adjacency constraints instead, and the model becomes a MILP that is still
inside the relational subset. method: sos2 states the same restriction as a
set and leaves the binaries to whichever sink needs them.
examples/piecewise.yaml · back to all models