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 — and a study that models 2030 hourly and 2050 in four-hour blocks is
asking for exactly the opposite.
So snapshot is one flat dimension carrying \(\mathrm{period}\) as a
lookup, the same way generator
carries \(\mathrm{bus}\) in transport. Ragged periods then cost
nothing: a lookup is a per-row column, and four snapshots in 2030 beside two in
2050 is just a column with four of one value and two of another.
Both directions of one mapping¶
Grouping reads the lookup one way:
sum(p, by=period_of) is a per-period CO₂ budget, and
monthly_budget is the same construct on a different
lookup.
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:
at and sum(by=) take the same one argument because the lookup names one
mapping table and the operator says which direction it is walked.
A per-period parameter needs neither: data prep can join it onto the
snapshot index before the model sees it. p_nom is a variable, and a
variable is not data to be joined — which is the line between the two, and why
the pullback is a construct in the language rather than a step before it.
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 --- investment periods, the grouping capacity is decided over |
| \(\mathcal{G}\) | index \(g\) --- generator --- generating units |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(\mathit{load}\) | load over \(\mathcal{T}\) --- demand to be met |
| \(\mathit{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 |
| \(\mathit{opex}\) | opex over \(\mathcal{G}\) --- cost of running a generator for one snapshot-hour |
| \(\mathit{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 |
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
lookups:
period_of: {over: snapshot, into: 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
foreach: [snapshot, generator]
bounds:
lower: 0
p_nom:
description: capacity a generator holds for the whole of a period
foreach: [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
foreach: [snapshot, generator]
expression: p <= at(p_nom, by=period_of)
balance:
foreach: [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: p * opex * weight + 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 lpspec call binds 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['snapshot'].set_index('snapshot')['period_of'].rename('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¶
Costs are chosen so each period picks a different technology, which is what makes the per-period capacity visible rather than incidental:
| 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 are the reason the two periods are comparable at all: a coarse snapshot standing for four hours contributes four hours of operating cost, so a period is not made cheap by being modelled coarsely.