Skip to content

piecewise_ragged

Per-generator cost curves of different lengths, each as long as its own data.

✔ Agrees with hand-written linopy 0.9.0 — objective 426, matched to rtol=1e-09.

The problem

A breakpoint dimension is one axis for the whole system, but a curve is per unit. The hydro unit here has two breakpoints, the coal plant three, the gas turbine four. Nothing in the model has to know which is longest.

\[p_{t,g} = \sum_{k \in \mathcal{K}_g} \lambda_{t,g,k}\, x_{g,k}, \quad \mathrm{cost}_{t,g} \ge \sum_{k \in \mathcal{K}_g} \lambda_{t,g,k}\, y_{g,k}, \quad \sum_{k \in \mathcal{K}_g} \lambda_{t,g,k} = 1\]

The weights run over \(\mathcal{K}_g\), the curve's own breakpoint set, which is what points: bp_x says. Without it the shorter curves have to be padded out to the longest. Padding buys a weight per unused breakpoint, and method: convex refuses it, because a repeated point is not a strictly increasing breakpoint.

The same model, as math

Least-cost dispatch where each generator's cost curve has as many breakpoints as its data gives it — two for the hydro unit, four for the gas turbine — rather than as many as the longest curve in the system.

Sets

Symbol Meaning
\(\mathcal{T}\) index \(t\) — snapshot — dispatch periods
\(\mathcal{G}\) index \(g\) — generator — dispatchable units
\(\mathcal{B}\) index \(b\) — bp — breakpoints, as many as the longest curve needs

Parameters

Symbol Meaning
\(\mathrm{p}^{\mathrm{max}}\) p_max over \(\mathcal{G}\) — maximum dispatch
\(\mathrm{load}\) load over \(\mathcal{T}\) — demand to be met
\(\mathrm{bp\_x}\) bp_x over \(\mathcal{G} \times \mathcal{B}\) — breakpoint dispatch levels, one curve per generator and no two the same length
\(\mathrm{bp\_y}\) bp_y over \(\mathcal{G} \times \mathcal{B}\) — cost at each breakpoint

Variables

Symbol Meaning
\(p\) p over \(\mathcal{T} \times \mathcal{G}\) — dispatched power
\(\mathit{op\_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.

Objective

\[ \min \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} \mathit{op\_cost}_{t,g} \]

Subject to

balance

\[ \sum_{g \in \mathcal{G}} p_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T} \]

cost_curve

\[ \mathit{op\_cost}_{t,g} \ge \mathrm{conv}_{b \in \mathcal{B} \,:\, \mathrm{bp\_x}_{g,b} \text{ is defined}}(\mathrm{bp\_x}_{g,b},\ \mathrm{bp\_y}_{g,b})(p_{t,g}) \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \]

Variable domains

p

\[ 0 \le p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \]

op_cost

\[ \mathit{op\_cost}_{t,g} \ge 0 \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \]

Assumptions

cost_curve_complete

\[ \mathrm{bp\_x}_{g,b} \text{ is defined} \wedge \mathrm{bp\_y}_{g,b} \text{ is defined} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{bp\_x}_{g,b} \text{ is defined} \]

cost_curve_increasing

\[ \mathrm{bp\_x}_{g,b \boxminus_{0} 1} < \mathrm{bp\_x}_{g,b} \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{bp\_x}_{g,b} \text{ is defined} \wedge \mathrm{bp\_x}_{g,b - 1} \text{ is defined} \]

cost_curve_curvature

\[ \left( \mathrm{bp\_y}_{g,b} - \mathrm{bp\_y}_{g,b \boxminus_{0} 1} \right) \cdot \left( \mathrm{bp\_x}_{g,b \boxplus_{0} 1} - \mathrm{bp\_x}_{g,b} \right) \le \left( \mathrm{bp\_y}_{g,b \boxplus_{0} 1} - \mathrm{bp\_y}_{g,b} \right) \cdot \left( \mathrm{bp\_x}_{g,b} - \mathrm{bp\_x}_{g,b \boxminus_{0} 1} \right) \qquad \forall\, g \in \mathcal{G},\ b \in \mathcal{B} \,:\, \mathrm{bp\_x}_{g,b} \text{ is defined} \wedge \mathrm{bp\_x}_{g,b - 1} \text{ is defined} \wedge \mathrm{bp\_x}_{g,b + 1} \text{ is defined} \]

cost_curve_contiguous

\[ \lvert \{ b \in \mathcal{B} \,:\, \mathrm{bp\_x}_{g,b} \text{ is defined} \wedge \neg \left( \mathrm{bp\_x}_{g,b - 1} \text{ is defined} \right) \} \rvert = 1 \qquad \forall\, g \in \mathcal{G} \]

The tabs start from the instance's tables — one frame per parameter.

description: >-
  Least-cost dispatch where each generator's cost curve has as many breakpoints
  as its data gives it — two for the hydro unit, four for the gas turbine —
  rather than as many as the longest curve in the system.

dimensions:
  snapshot:
    description: dispatch periods
    dtype: int
  generator:
    description: dispatchable units
    dtype: str
  bp:
    description: breakpoints, as many as the longest curve needs
    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 and no two the same length
    dims: [generator, bp]
  bp_y:
    description: cost at each breakpoint
    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: >-
      each unit's own curve — `points: bp_x` says a curve runs as far as its own
      breakpoints do, so the hydro unit pays for two weights and the gas turbine
      for four
    over: bp
    points: bp_x
    links:
      - [p, bp_x]
      - [op_cost, bp_y, ">="]
    method: convex

constraints:
  balance:
    description: every period's demand is met
    dims: [snapshot]
    expression: sum(p, over=generator) == load

objective:
  sense: minimize
  description: total operating cost, taken off each unit's own curve
  expression: sum(sum(op_cost, over=generator), over=snapshot)
# sources: parameter name -> frame or parquet path
with sps.solve(to_spec('examples/piecewise_ragged.yaml').expand(), sources) as solution:
    solution.objective  # 426.0
    solution.dual('balance')

The model-building half of examples/ports/references/linopy/piecewise_ragged.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']
    reach = tables['bp_x'].groupby('generator')['value'].max().reindex(p_max.index)

    m = linopy.Model()
    p = m.add_variables(lower=0, upper=reach, coords=[load.index, p_max.index], name='p')
    op_cost = m.add_variables(lower=0, coords=[load.index, p_max.index], name='op_cost')
    for g, lines in segments(tables).items():
        for k, (slope, intercept) in enumerate(lines):
            m.add_constraints(
                op_cost.sel(generator=g) - slope * p.sel(generator=g) >= intercept,
                name=f'chord_{g}_{k}',
            )
    m.add_constraints(p.sum('generator') == load, name='balance')
    m.add_objective(op_cost.sum())
    return m

What it exercises

points: is what makes the curve its own. The weights, and the segment binaries where a method declares them, exist only where the mask does, so the hydro unit carries two rather than four. No value is asked for at a breakpoint the curve leaves out. A gap in the middle of a curve is refused when data attaches: the marked breakpoints have to follow one another, though they need not start at the head of the axis.

The linopy reference reaches the same optimum from the other formulation: segment lines rather than weights, which is exact for a convex curve under minimisation and needs no auxiliary variable. Two formulations agreeing is a stronger check than two spellings of one. Both sides stay a pure LP, so this model keeps its duals.


examples/piecewise_ragged.yaml · back to all models