multi_period¶
Capacity decided once per investment period, binding at every snapshot inside it — and the periods need not be the same size.
✔ Agrees with hand-written linopy 0.9.0 — objective 10020, matched to
rtol=1e-09.
The problem¶
Two dimensions cannot state this at the resolution a real study wants.
period × snapshot is a rectangle, so every period gets the same number of
snapshots. A study that models 2030 hourly and 2050 in four-hour blocks wants
the opposite.
So snapshot is one flat dimension carrying \(\mathrm{period}\) as a
relation, as generator
carries \(\mathrm{bus}\) in transport. Ragged periods then cost
nothing: a relation is a per-row column, and four snapshots in 2030 beside two in
2050 is a column with four of one value and two of another.
Both directions of one mapping¶
Grouping reads the relation one way:
sum(p, by=period_of, over=snapshot, into=period) is a per-period CO₂ budget, and
monthly_budget is the same construct on a different
relation.
within_cap reads it the other way. Capacity lives on period and binds at
each snapshot, so a coarse quantity is pulled onto a fine one:
within_cap:
dims: [snapshot, generator]
expression: p <= at(p_nom, by=period_of, over=period, into=snapshot)
at and sum(by=) take the same argument: the relation names one table, and
the operator says which direction it is walked.
A per-period parameter needs neither: join it onto the snapshot index
before the model sees it. p_nom is a variable, which no join can reach,
so the pullback is a construct in the language.
The same model, as math
Least-cost investment and dispatch together: capacity is decided once per period and binds at every snapshot inside it, and a snapshot's weight says how much time it stands for, so periods of different size are comparable.
Sets¶
| Symbol | Meaning |
|---|---|
| \(\mathcal{T}\) | index \(t\) — snapshot with \(\mathrm{period\_of}: \mathcal{T} \to \mathcal{E}\) — dispatch periods, each falling in one investment period |
| \(\mathcal{E}\) | index \(e\) — period with \(\mathrm{period\_of}: \mathcal{T} \to \mathcal{E}\) — investment periods, the grouping capacity is decided over |
| \(\mathcal{G}\) | index \(g\) — generator — generating units |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(\mathrm{load}\) | load over \(\mathcal{T}\) — demand to be met |
| \(\mathrm{weight}\) | weight over \(\mathcal{T}\) — what one snapshot stands for — a 2050 snapshot represents four hours, so the operating cost of a coarse period is not understated against a fine one |
| \(\mathrm{opex}\) | opex over \(\mathcal{G}\) — cost of running a generator for one snapshot-hour |
| \(\mathrm{capex}\) | capex over \(\mathcal{G} \times \mathcal{E}\) — cost of holding a unit of capacity through a period |
Variables¶
| Symbol | Meaning |
|---|---|
| \(p\) | p over \(\mathcal{T} \times \mathcal{G}\) — output of a generator in a snapshot |
| \(p^{\mathrm{nom}}\) | p_nom over \(\mathcal{E} \times \mathcal{G}\) — capacity a generator holds for the whole of a period |
Upright is what the data supplies — a parameter such as \(\mathrm{load}\), 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.
Objective¶
Subject to¶
within_cap
balance
Variable domains¶
p
p_nom
The tabs start from the instance's tables — one frame per parameter.
description: >-
Least-cost investment and dispatch together: capacity is decided once per
period and binds at every snapshot inside it, and a snapshot's weight says
how much time it stands for, so periods of different size are comparable.
dimensions:
snapshot:
description: dispatch periods, each falling in one investment period
dtype: int
period:
description: investment periods, the grouping capacity is decided over
dtype: int
generator:
description: generating units
dtype: str
relations:
period_of: {key: snapshot, values: period}
parameters:
load:
description: demand to be met
dims: [snapshot]
weight:
description: >-
what one snapshot stands for — a 2050 snapshot represents four hours, so
the operating cost of a coarse period is not understated against a fine
one
dims: [snapshot]
opex:
description: cost of running a generator for one snapshot-hour
dims: [generator]
capex:
description: cost of holding a unit of capacity through a period
dims: [generator, period]
variables:
p:
description: output of a generator in a snapshot
dims: [snapshot, generator]
bounds:
lower: 0
p_nom:
description: capacity a generator holds for the whole of a period
dims: [period, generator]
bounds:
lower: 0
upper: 100
constraints:
within_cap:
description: output in a snapshot is capped by the capacity of the period it falls in
dims: [snapshot, generator]
expression: p <= at(p_nom, by=period_of, over=period, into=snapshot)
balance:
dims: [snapshot]
expression: sum(p, over=generator) == load
objective:
sense: minimize
description: weighted operating cost over the horizon, plus what the capacity costs to hold
expression: sum(p * opex * weight) + sum(p_nom * capex)
The model-building half of examples/ports/references/linopy/multi_period.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``.
"""
load: pd.Series = tables['load'].set_index('snapshot')['value']
weight: pd.Series = tables['weight'].set_index('snapshot')['value']
opex: pd.Series = tables['opex'].set_index('generator')['value']
capex = xr.DataArray(tables['capex'].pivot(index='period', columns='generator', values='value'))
period = xr.DataArray(tables['period_of'].set_index('snapshot')['period'])
m = linopy.Model()
p = m.add_variables(lower=0, coords=[load.index, opex.index], name='p')
p_nom = m.add_variables(lower=0, upper=100, coords=[capex.indexes['period'], opex.index], name='p_nom')
m.add_constraints(p <= p_nom.sel(period=period), name='within_cap')
m.add_constraints(p.sum('generator') == load, name='balance')
m.add_objective((p * opex * weight).sum() + (p_nom * capex).sum())
return m
Reading the answer¶
On the smaller instance tests/test_at.py holds, costs are chosen so each
period picks a different technology, which makes the per-period capacity
visible:
| period | wind | gas |
|---|---|---|
| 2030 | 20 | 10 |
| 2050 | 60 | 0 |
2030 peaks at 30 and splits the build: wind is dearer to install but free to run. 2050 peaks at 60 with every snapshot weighted four times, so the operating term dominates and the whole build goes to wind. Objective 750.0, agreed integer for integer by both lanes.
The weights make the two periods comparable: a coarse snapshot standing for four hours contributes four hours of operating cost, so a period is not made cheap by being modelled coarsely.