Skip to content

piecewise_conversion

Converters whose flows share one curve, where how many flows is data.

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

The problem

A converter ties its flows together through one operating point: a boiler burns fuel to make heat, a CHP (combined heat and power) unit burns fuel to make heat and power. The curve is one convex combination per converter. How many flows it ties is a property of the system, not of the file: two here, three there, and a unit with a fourth is a row in a table.

\[\mathit{rate}_{f,t} = \sum_{k \in \mathcal{K}_{c(f)}} \lambda_{c(f),t,k}\, v_{f,k} \qquad \forall\thinspace f, t\]

piecewise: cannot say that: its links: are a list, so the arity would have to be written out. The formulation it would have emitted is four ordinary declarations:

what how
the weights a variable over [converter, time, bp], masked by where: bp_present
on one segment sos: type: 2 over that variable
one operating point sum(weight, over=bp) == 1
the tie one row per flow, reading its converter's weights through at(…, by=converter_of, over=converter, into=flow)
The same model, as math

Least-cost heat and power from two converters whose flows are tied to one piecewise curve each — a boiler tying two flows, a CHP unit tying three, and neither number written anywhere in the file.

Sets

Symbol Meaning
\(\mathcal{T}\) index \(t\) — time — dispatch periods
\(\mathcal{C}\) index \(c\) — converter with \(\mathrm{converter\_of}: \mathcal{F} \to \mathcal{C}\) — units converting one carrier into others
\(\mathcal{F}\) index \(f\) — flow with \(\mathrm{converter\_of}: \mathcal{F} \to \mathcal{C}\) — a converter's inputs and outputs, one row each
\(\mathcal{B}\) index \(b\) — bp — breakpoints, as many as the longest curve needs

Parameters

Symbol Meaning
\(\mathrm{bp\_rate}\) bp_rate over \(\mathcal{F} \times \mathcal{B}\) — what each flow runs at, at each breakpoint of its converter's curve
\(\mathrm{bp\_present}\) bp_present over \(\mathcal{C} \times \mathcal{B}\) — how far each converter's curve runs
\(\mathrm{rate}^{\mathrm{max}}\) rate_max over \(\mathcal{F}\) — what each flow runs at when its converter is at its last breakpoint
\(\mathrm{is\_heat}\) is_heat over \(\mathcal{F}\) — which flows deliver heat
\(\mathrm{is\_power}\) is_power over \(\mathcal{F}\) — which flows deliver power
\(\mathrm{fuel\_price}\) fuel_price over \(\mathcal{F}\) — what a unit of each input flow costs
\(\mathrm{heat\_demand}\) heat_demand over \(\mathcal{T}\) — heat to be delivered
\(\mathrm{power\_demand}\) power_demand over \(\mathcal{T}\) — power to be delivered

Variables

Symbol Meaning
\(\mathit{rate}\) rate over \(\mathcal{F} \times \mathcal{T}\) — what each flow runs at
\(\mathit{weight}\) weight over \(\mathcal{C} \times \mathcal{T} \times \mathcal{B}\) — how much of each breakpoint the converter's operating point is made of — one convex combination per converter and period, over the breakpoints its own curve runs to

Upright is what the data supplies — a parameter such as \(\mathrm{bp\_rate}\), a coordinate map, a label — and italic is what the solver chooses, such as \(\mathit{rate}\). An index is italic too, being what a quantifier chooses, and a set is script.

Objective

\[ \min \sum_{t \in \mathcal{T}} \sum_{f \in \mathcal{F}} \mathit{rate}_{f,t} \cdot \mathrm{fuel\_price}_{f} \]

Subject to

one_operating_point

\[ \sum_{b \in \mathcal{B}} \mathit{weight}_{c,t,b} = 1 \qquad \forall\, c \in \mathcal{C},\ t \in \mathcal{T} \]

on_the_curve

\[ \mathit{rate}_{f,t} = \sum_{b \in \mathcal{B}} \mathit{weight}_{\mathrm{converter\_of}(f),t,b} \cdot \mathrm{bp\_rate}_{f,b} \qquad \forall\, f \in \mathcal{F},\ t \in \mathcal{T} \]

heat_balance

\[ \sum_{f \in \mathcal{F}} \mathit{rate}_{f,t} \cdot \mathrm{is\_heat}_{f} = \mathrm{heat\_demand}_{t} \qquad \forall\, t \in \mathcal{T} \]

power_balance

\[ \sum_{f \in \mathcal{F}} \mathit{rate}_{f,t} \cdot \mathrm{is\_power}_{f} = \mathrm{power\_demand}_{t} \qquad \forall\, t \in \mathcal{T} \]

Variable domains

rate

\[ 0 \le \mathit{rate}_{f,t} \le \mathrm{rate}^{\mathrm{max}}_{f} \qquad \forall\, f \in \mathcal{F},\ t \in \mathcal{T} \]

weight

\[ 0 \le \mathit{weight}_{c,t,b} \le 1 \qquad \forall\, c \in \mathcal{C},\ t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{bp\_present}_{c,b} \]

on_one_segment

\[ \left( \mathit{weight}_{c,t,b} \right)_{b \in \mathcal{B}} \in \mathrm{SOS}2 \qquad \forall\, c \in \mathcal{C},\ t \in \mathcal{T} \]

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

description: >-
  Least-cost heat and power from two converters whose flows are tied to one
  piecewise curve each — a boiler tying two flows, a CHP unit tying three, and
  neither number written anywhere in the file.

dimensions:
  time:
    description: dispatch periods
    dtype: int
  converter:
    description: units converting one carrier into others
    dtype: str
  flow:
    description: a converter's inputs and outputs, one row each
    dtype: str
  bp:
    description: breakpoints, as many as the longest curve needs
    dtype: int

relations:
  converter_of:
    description: which converter a flow belongs to
    key: flow
    values: converter

parameters:
  bp_rate:
    description: what each flow runs at, at each breakpoint of its converter's curve
    dims: [flow, bp]
  bp_present:
    description: how far each converter's curve runs
    dims: [converter, bp]
    dtype: bool
  rate_max:
    description: what each flow runs at when its converter is at its last breakpoint
    dims: [flow]
  is_heat:
    description: which flows deliver heat
    dims: [flow]
  is_power:
    description: which flows deliver power
    dims: [flow]
  fuel_price:
    description: what a unit of each input flow costs
    dims: [flow]
  heat_demand:
    description: heat to be delivered
    dims: [time]
  power_demand:
    description: power to be delivered
    dims: [time]

variables:
  rate:
    description: what each flow runs at
    dims: [flow, time]
    bounds:
      lower: 0
      upper: rate_max
  weight:
    description: >-
      how much of each breakpoint the converter's operating point is made of —
      one convex combination per converter and period, over the breakpoints its
      own curve runs to
    dims: [converter, time, bp]
    where: bp_present
    bounds:
      lower: 0
      upper: 1

sos:
  on_one_segment:
    description: >-
      at most two weights, and those two neighbours — which is what puts the
      operating point on a segment of the curve rather than anywhere in its hull
    variable: weight
    along: bp
    type: 2

constraints:
  one_operating_point:
    description: each converter sits somewhere on its curve, in every period
    dims: [converter, time]
    expression: sum(weight, over=bp) == 1
  on_the_curve:
    description: every flow reads its own value off its converter's weights
    dims: [flow, time]
    expression: rate == sum(at(weight, by=converter_of, over=converter, into=flow) * bp_rate, over=bp)
  heat_balance:
    description: heat delivered meets the demand
    dims: [time]
    expression: sum(rate * is_heat, over=flow) == heat_demand
  power_balance:
    description: power delivered meets the demand
    dims: [time]
    expression: sum(rate * is_power, over=flow) == power_demand

objective:
  sense: minimize
  description: what the input flows cost
  expression: sum(sum(rate * fuel_price, over=flow), over=time)
# sources: parameter name -> frame or parquet path
with sps.solve(to_spec('examples/piecewise_conversion.yaml').expand(), sources) as solution:
    solution.objective  # 5990.0

The model-building half of examples/ports/references/linopy/piecewise_conversion.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``.
    """
    flows: pd.DataFrame = tables['flow'].set_index('flow')
    times = pd.Index(tables['time']['time'], name='time')
    present = tables['bp_present'].set_index(['converter', 'bp'])['value']
    runs_to = {c: int(present[c].sum()) for c in present.index.get_level_values('converter').unique()}

    m = linopy.Model()
    rate = m.add_variables(lower=0, coords=[pd.Index(flows.index, name='flow'), times], name='rate')

    for converter, members in flows.groupby('converter_of'):
        pairs = [
            (rate.sel(flow=flow, drop=True), linopy.breakpoints(curve_of(tables, flow, runs_to[converter])))
            for flow in members.index
        ]
        m.add_piecewise_formulation(*pairs, name=f'curve_{converter}')

    for carrier, demand in (('is_heat', 'heat_demand'), ('is_power', 'power_demand')):
        weights = tables[carrier].set_index('flow')['value'].reindex(flows.index)
        wanted = tables[demand].set_index('time')['value'].reindex(times)
        m.add_constraints((rate * weights).sum('flow') == wanted, name=demand)

    price = tables['fuel_price'].set_index('flow')['value'].reindex(flows.index)
    m.add_objective((rate * price).sum())
    return m

What it exercises

The arity is a row count. on_the_curve builds one row per flow, so a converter tying three expressions and one tying two are the same declaration. Nothing in the file says how many flows a converter has; the converter_of relation does, and it is data.

The mask is the variable's own. where: bp_present decides which weights exist, and an sos: set is over the members present, so the boiler's three-breakpoint curve and the CHP's four sit on one axis with nothing padded. A solver without SOS gets binaries and linking rows for the same set, which multiply by the member's own upper bound — 1 here, because a weight is at most one.

linopy's add_piecewise_formulation ties N expressions to one basis too, but its pairs are an argument list, so the linopy tab writes the arity out per converter in a Python loop. That loop is what moves into the data here.


examples/piecewise_conversion.yaml · back to all models