pypsa_mixed_cycling¶
PyPSA's cyclic_state_of_charge is a per-unit flag, so one network runs both
regimes at once: a unit that must end each horizon where it began, beside one
handed a level it may simply spend.
The problem¶
Rung 3 seeds every storage from state_of_charge_initial; rung 4
closes every storage on itself. A real PyPSA model has both in the same
StorageUnit frame, because the flag is a column:
The two regimes are one rule and a different predecessor. A cyclic unit's
first snapshot carries from its last; a seeded unit's carries from a level in the
data. So the thing that varies is not the rule, it is the opening level — one
quantity with three answers, which is what a named expression's cases: says:
| Case | When | What the unit opens with |
|---|---|---|
wrapped |
cyclic |
the unit's own last snapshot (edge='wrap') |
seeded |
not cyclic and position(snapshot) == 0 |
soc_initial |
carried |
not cyclic and position(snapshot) > 0 |
the snapshot before |
The balance is then written once, against opening_level. The cases have to
partition [snapshot, storage] and are checked to at load, with no data read:
not is a real complement over a boolean column, and the two position clauses
split the rest, so every coordinate falls in exactly one — including a unit whose
flag row is missing, which reads as not cyclic.
Written as three constraints instead, the seeded unit's first row is the one
shift leaves absent, so a fourth block has to write it back and
diagnostics().omissions reports the dropped row. Under cases: there is no
dropped row to report: the arm that vacates the first position is the arm that
does not claim it.
The model¶
The same model, as math
PyPSA's cyclic_state_of_charge is a column of the StorageUnit frame, so one network runs both regimes at once: a unit that must end each horizon where it began, beside one handed a level it may simply spend. What differs between them is the level a unit opens a snapshot with, so that is the cased quantity and the balance is written once. Optimum 4800.0, from PyPSA itself.
Sets¶
| Symbol | Meaning |
|---|---|
| \(\mathcal{T}\) | index \(t\) — snapshot — dispatch periods |
| \(\mathcal{B}\) | index \(b\) — bus — network nodes |
| \(\mathcal{G}\) | index \(g\) — generator with \(\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B}\) — generating units, each sitting on one bus |
| \(\mathcal{S}\) | index \(s\) — storage with \(\mathrm{storage\_bus}: \mathcal{S} \to \mathcal{B}\) — storage units, each sitting on one bus |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(\mathrm{cyclic}\) | cyclic over \(\mathcal{S}\) — whether a unit closes its own horizon. PyPSA's flag, and the one column that decides which of the two balance rules a unit obeys |
| \(\mathrm{p}^{\mathrm{nom}}\) | p_nom over \(\mathcal{G}\) — installed capacity of a generator |
| \(\mathrm{marginal\_cost}\) | marginal_cost over \(\mathcal{G}\) — cost of one unit of generation |
| \(\mathrm{storage\_p\_nom}\) | storage_p_nom over \(\mathcal{S}\) — how fast a storage unit may charge or discharge |
| \(\mathrm{soc}^{\mathrm{max}}\) | soc_max over \(\mathcal{S}\) — most energy a storage unit may hold |
| \(\mathrm{soc}^{\mathrm{initial}}\) | soc_initial over \(\mathcal{S}\) — the level a seeded unit begins with. A cyclic unit has one in the data and never reads it — its own last snapshot is its opening level |
| \(\mathrm{load}\) | load over \(\mathcal{T} \times \mathcal{B}\) — demand to be met at a bus |
Variables¶
| Symbol | Meaning |
|---|---|
| \(p\) | p over \(\mathcal{T} \times \mathcal{G}\) — output of a generator in a snapshot |
| \(p^{\mathrm{dispatch}}\) | p_dispatch over \(\mathcal{T} \times \mathcal{S}\) — power a storage unit puts onto its bus |
| \(p^{\mathrm{store}}\) | p_store over \(\mathcal{T} \times \mathcal{S}\) — power a storage unit takes off its bus |
| \(\mathit{soc}\) | soc over \(\mathcal{T} \times \mathcal{S}\) — energy in the store at the end of a snapshot |
Upright is what the model is given — a parameter such as \(\mathrm{cyclic}\), 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 \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.
\(\mathrm{pos}(t)\) denotes where index \(t\) sits along its dimension's own order — the order shift walks, 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.
Objective¶
Subject to¶
nodal_balance
energy_balance
Definitions¶
opening_level
Variable domains¶
p
p_dispatch
p_store
soc
The tabs start from the instance's tables — one frame per parameter.
description: >-
PyPSA's `cyclic_state_of_charge` is a column of the StorageUnit frame, so one
network runs both regimes at once: a unit that must end each horizon where it
began, beside one handed a level it may simply spend. What differs between them
is the level a unit opens a snapshot with, so that is the cased quantity and
the balance is written once. Optimum 4800.0, from PyPSA itself.
dimensions:
snapshot:
description: dispatch periods
dtype: int
bus:
description: network nodes
dtype: str
generator:
description: generating units, each sitting on one bus
dtype: str
storage:
description: storage units, each sitting on one bus
dtype: str
lookups:
gen_bus:
description: the bus a generator sits on
over: generator
into: bus
storage_bus:
description: the bus a storage unit sits on
over: storage
into: bus
parameters:
cyclic:
description: >-
whether a unit closes its own horizon. PyPSA's flag, and the one column
that decides which of the two balance rules a unit obeys
dims: [storage]
dtype: bool
p_nom:
description: installed capacity of a generator
dims: [generator]
marginal_cost:
description: cost of one unit of generation
dims: [generator]
storage_p_nom:
description: how fast a storage unit may charge or discharge
dims: [storage]
soc_max:
description: most energy a storage unit may hold
dims: [storage]
soc_initial:
description: >-
the level a seeded unit begins with. A cyclic unit has one in the data and
never reads it — its own last snapshot is its opening level
dims: [storage]
load:
description: demand to be met at a bus
dims: [snapshot, bus]
variables:
p:
description: output of a generator in a snapshot
foreach: [snapshot, generator]
bounds:
lower: 0
upper: p_nom
p_dispatch:
description: power a storage unit puts onto its bus
foreach: [snapshot, storage]
bounds:
lower: 0
upper: storage_p_nom
p_store:
description: power a storage unit takes off its bus
foreach: [snapshot, storage]
bounds:
lower: 0
upper: storage_p_nom
soc:
description: energy in the store at the end of a snapshot
foreach: [snapshot, storage]
bounds:
lower: 0
upper: soc_max
expressions:
opening_level:
description: >-
the level a unit carries into a snapshot. The two regimes and the seeded
unit's boundary are three answers to that one question, so they are three
cases of one quantity rather than three copies of one inequality
foreach: [snapshot, storage]
cases:
wrapped:
when: "cyclic"
expression: shift(soc, over=snapshot, offset=1, edge='wrap')
description: a cyclic unit's first snapshot inherits from its last, so it ends where it began
seeded:
when: "not cyclic and position(snapshot) == 0"
expression: soc_initial
description: a seeded unit's first snapshot reads the level it was handed
carried:
when: "not cyclic and position(snapshot) > 0"
expression: shift(soc, over=snapshot, offset=1)
description: and every later one carries from the snapshot before
constraints:
nodal_balance:
description: what is generated at a bus, plus what the stores give back, meets the load there
foreach: [snapshot, bus]
expression: >-
sum(p, by=gen_bus)
+ sum(p_dispatch, by=storage_bus)
- sum(p_store, by=storage_bus)
== load
energy_balance:
description: >-
one rule for both regimes: what a unit holds is what it opened with, plus
what it took off its bus, less what it gave back
foreach: [snapshot, storage]
expression: soc == opening_level + p_store - p_dispatch
objective:
sense: minimize
description: total cost of generation; storage is free to operate here
expression: sum(p * marginal_cost)
The model-building half of examples/ports/references/pypsa/pypsa_mixed_cycling.py:
def build(tables: dict[str, pd.DataFrame]) -> pypsa.Network:
"""The port's tables as a PyPSA network, column for column.
``tables`` is the same mapping the lpspec call binds as ``sources``.
``max_hours`` is the ratio PyPSA stores; the port carries the product it
implies (``soc_max``), because a bound there takes a name, not arithmetic.
"""
n = pypsa.Network()
n.set_snapshots(tables['snapshot']['snapshot'])
n.add('Bus', tables['bus']['bus'])
generators: pd.DataFrame = tables['generator'].set_index('generator')
storages: pd.DataFrame = tables['storage'].set_index('storage')
n.add(
'Generator',
generators.index,
bus=generators['gen_bus'],
p_nom=tables['p_nom'].set_index('generator')['value'],
marginal_cost=tables['marginal_cost'].set_index('generator')['value'],
)
p_nom: pd.Series = tables['storage_p_nom'].set_index('storage')['value']
n.add(
'StorageUnit',
storages.index,
bus=storages['storage_bus'],
p_nom=p_nom,
max_hours=tables['soc_max'].set_index('storage')['value'] / p_nom,
cyclic_state_of_charge=tables['cyclic'].set_index('storage')['value'],
state_of_charge_initial=tables['soc_initial'].set_index('storage')['value'],
)
load: pd.DataFrame = tables['load'].pivot(index='snapshot', columns='bus', values='value')
for bus in tables['bus']['bus']:
n.add('Load', f'load_{bus}', bus=bus, p_set=load[bus])
return n
Both flags bind¶
Neither regime is decoration on this instance — flipping either changes the answer, in opposite directions:
| Instance | Objective |
|---|---|
as shipped — ring cyclic, seasonal seeded |
4800.0 |
ring flipped to seeded |
4400.0 |
seasonal flipped to cyclic |
7200.0 |
ring carries a soc_initial of 20 that it never reads, which is the point of
the first row: a cyclic unit ignores the level in the data, and flipping its flag
hands it 400 of free energy. seasonal's 30 is worth 2400, because a cyclic unit
must give back everything it spends.
What the answer looks like¶
snapshot price ring seasonal
0 20 15 30 ← seasonal opens at its given level; ring opens where it will close
1 80 0 15 ← both discharge into the peak
2 20 10 15
3 20 0 0 ← ring is back where it started; seasonal need not be
The price is the dual of nodal_balance: 20 where the base plant is marginal
and 80 at the snapshot where the peaker runs, which is what makes moving energy
worth anything at all.
What this rung is for¶
It is the shape neither storage rung has. Rung 3 and rung 4 differ by one deleted
where, and each is uniform — so nothing in the corpus exercised a data column
choosing between two boundary regimes until this one.
It was first written as three blocks under complementary masks, which worked and
needed no language feature. What cases: changes is where the variation sits:
the regimes are three answers to what does this unit open with, and the model
now says that rather than repeating one inequality three times under three
masks. The inequality is written once, and the partition is proved at load
instead of being a property of three where strings a reader has to check
against each other.