Skip to content

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

\[p_{t,g} \quad\le\quad \hat p_{\thinspace\mathrm{period}(t),\thinspace g}\]

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:

within_cap:
  foreach: [snapshot, generator]
  expression: p <= at(p_nom, by=period_of)

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

\[\min \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{opex}_{g} \cdot \mathit{weight}_{t} + \sum_{e \in \mathcal{E},\enspace g \in \mathcal{G}} p^{\mathrm{nom}}_{e,g} \cdot \mathit{capex}_{g,e}\]

Subject to

within_cap

\[p_{t,g} \le p^{\mathrm{nom}}_{\mathrm{period\_of}(t),g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}\]

balance

\[\sum_{g \in \mathcal{G}} p_{t,g} = \mathit{load}_{t} \qquad \forall\thinspace t \in \mathcal{T}\]

Variable domains

p

\[p_{t,g} \ge 0 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}\]

p_nom

\[0 \le p^{\mathrm{nom}}_{e,g} \le 100 \qquad \forall\thinspace e \in \mathcal{E},\enspace g \in \mathcal{G}\]

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
# sources: parameter name -> frame or parquet path
with lps.solve('examples/multi_period.yaml', sources) as solution:
    solution.objective  # 10020.0
    solution.dual('balance')

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.