storage¶
Dispatch plus a battery, and the only construct in the language whose cost is not obviously linear.
✔ Agrees with hand-written linopy 0.9.0 — objective 5650, matched to
rtol=1e-09.
The problem¶
State of charge links each snapshot to the one before it, cyclically:
with \(s-1\) wrapping at the horizon, so the battery ends where it started. The
model writes the charging efficiency as the literal 0.9 rather than
declaring a parameter, so it appears here as a number and not as an \(\eta\).
The model¶
The same model, as math
Dispatch plus a battery whose state of charge is closed into a cycle: the horizon ends where it began.
Sets¶
| Symbol | Meaning |
|---|---|
| \(\mathcal{S}\) | index \(s\) — snapshot — dispatch periods, cyclic at the horizon |
| \(\mathcal{G}\) | index \(g\) — generator — generating units |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(\bar p\) | p_max over \(\mathcal{G}\) — installed capacity |
| \(c\) | cost over \(\mathcal{G}\) — marginal cost |
| \(\ell\) | load over \(\mathcal{S}\) — demand to be met |
Variables¶
| Symbol | Meaning |
|---|---|
| \(p\) | p over \(\mathcal{S} \times \mathcal{G}\) — output of a generator in a snapshot |
| \(\mathrm{charge}\) | charge over \(\mathcal{S}\) — energy into the store |
| \(\mathrm{discharge}\) | discharge over \(\mathcal{S}\) — energy out of the store |
| \(\mathrm{soc}\) | soc over \(\mathcal{S}\) — state of charge carried into the next snapshot |
\(t \ominus k\) denotes cyclic translation: index \(t-k\) taken modulo the size of the dimension (roll). Plain \(t-k\) (shift) has no wraparound — terms translated past the edge are simply absent.
Objective¶
Subject to¶
power_balance
soc_balance
Variable domains¶
p
charge
discharge
soc
The tabs start from the instance's tables — one frame per parameter.
description: >-
Dispatch plus a battery whose state of charge is closed into a cycle: the
horizon ends where it began.
dimensions:
snapshot:
description: dispatch periods, cyclic at the horizon
dtype: int
generator:
description: generating units
dtype: str
parameters:
p_max:
description: installed capacity
dims: [generator]
cost:
description: marginal cost
dims: [generator]
load:
description: demand to be met
dims: [snapshot]
variables:
p:
description: output of a generator in a snapshot
dims: [snapshot, generator]
bounds:
lower: 0
upper: p_max
charge:
description: energy into the store
dims: [snapshot]
bounds:
lower: 0
upper: 30
discharge:
description: energy out of the store
dims: [snapshot]
bounds:
lower: 0
upper: 30
soc:
description: state of charge carried into the next snapshot
dims: [snapshot]
bounds:
lower: 0
upper: 100
constraints:
power_balance:
description: generation plus what the store gives back covers the load, net of charging
dims: [snapshot]
expression: sum(p, over=generator) + discharge - charge == load
soc_balance:
description: >-
the level carried out of a snapshot is the one carried in plus what was
stored, minus what was taken — and it wraps at the horizon, so the first
snapshot inherits from the last
dims: [snapshot]
expression: soc == shift(soc, along=snapshot, offset=1, edge='wrap') + charge * 0.9 - discharge
objective:
sense: minimize
description: total cost of generation; storing and releasing energy is free here
expression: sum(p * cost)
The model-building half of examples/ports/references/linopy/storage.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']
cost: pd.Series = tables['cost'].set_index('generator')['value']
load: pd.Series = tables['load'].set_index('snapshot')['value']
snapshots = load.index
m = linopy.Model()
p = m.add_variables(lower=0, upper=p_max, coords=[snapshots, p_max.index], name='p')
charge = m.add_variables(lower=0, upper=30, coords=[snapshots], name='charge')
discharge = m.add_variables(lower=0, upper=30, coords=[snapshots], name='discharge')
soc = m.add_variables(lower=0, upper=100, coords=[snapshots], name='soc')
m.add_constraints(p.sum('generator') + discharge - charge == load, name='power_balance')
m.add_constraints(soc == soc.roll(snapshot=1) + 0.9 * charge - discharge, name='soc_balance')
m.add_objective((p * cost).sum())
return m
What it exercises¶
shift(soc, along=snapshot, offset=1, edge='wrap') is the whole of it. One
term reaches one position back along snapshot. With edge='wrap' the first
snapshot reads the last, which makes the storage cyclic without a boundary
condition written out by hand. Without edge= the same node does not wrap,
and a position translated past the edge contributes nothing.
It is also the one plan shape whose cost is not obviously linear in the model size, so the benchmarks list it under Not measured yet.
examples/storage.yaml · back to all models