Skip to content

PyPSA multi-period investment — an asset exists for the years it exists

A build year and a lifetime decide which rows an asset appears in, and each period's costs carry its own discount.

✔ Verified against pypsa 1.2.4 (its own linopy 0.9.0) — objective 85300.0, matched to rtol=1e-09.

The corpus has a multi-period model already, but not PyPSA's version of it: no build years, no lifetimes, no discounting, and no asset that exists in one period and not the next. This rung is those four.

Three generators, one of each case. coal is built in 2030 with a lifetime of 5 and has retired by 2040; gas is built in 2030 and lives through both; wind is built in 2040 and exists in no row before it. All three are built and all three run, so none is decoration.

build year lifetime exists in capacity built
coal 2030 5 2030 40
gas 2030 40 2030, 2040 60
wind 2040 30 2040 80

The model

The same model, as math

PyPSA multi-period investment: a build year and a lifetime decide which periods an asset exists in, so a generator that has retired has no dispatch to choose, and capacity is paid for once per period it stands in — discounted by that period's weight. One unit retires between the two periods, one is built for the second, one lives through both. Optimum 85300.0, from PyPSA itself.

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 and paid over
\(\mathcal{G}\) index \(g\) --- generator --- generating units, each existing in some periods and not others

Parameters

Symbol Meaning
\(\mathit{load}\) load over \(\mathcal{T}\) --- demand to be met
\(\mathit{period}^{\mathrm{weight}}\) period_weight over \(\mathcal{E}\) --- what one period's costs are worth at the horizon's start — the discount that makes a 2040 decision comparable with a 2030 one
\(\mathit{opex}\) opex over \(\mathcal{G}\) --- cost of one unit of output
\(\mathit{capex}\) capex over \(\mathcal{G}\) --- cost of holding one unit of capacity through one period
\(p^{\mathrm{nom,max}}\) p_nom_max over \(\mathcal{G}\) --- most capacity a generator may build
\(\mathit{activity}\) activity over \(\mathcal{E} \times \mathcal{G}\) --- 1 where a generator exists in a period and 0 where it does not — PyPSA derives this from a build year and a lifetime, and it decides both what may run and what is paid for

Variables

Symbol Meaning
\(p\) p over \(\mathcal{T} \times \mathcal{G}\) --- output of a generator in a snapshot, held at zero in the snapshots whose period the generator does not exist in
\(p^{\mathrm{nom}}\) p_nom over \(\mathcal{G}\) --- capacity built at a generator

Objective

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

Subject to

within_capacity

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

power_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}}_{g} \le p^{\mathrm{nom,max}}_{g} \qquad \forall\thinspace g \in \mathcal{G}\]

The tabs start from the instance’s tables — one frame per parameter.

description: >-
  PyPSA multi-period investment: a build year and a lifetime decide which
  periods an asset exists in, so a generator that has retired has no dispatch to
  choose, and capacity is paid for once per period it stands in — discounted by
  that period's weight. One unit retires between the two periods, one is built
  for the second, one lives through both.
  Optimum 85300.0, from PyPSA itself.

dimensions:
  snapshot:
    description: dispatch periods, each falling in one investment period
    dtype: int
  period:
    description: investment periods, the grouping capacity is decided and paid over
    dtype: int
  generator:
    description: generating units, each existing in some periods and not others
    dtype: str

lookups:
  period_of:
    description: the investment period a snapshot falls in
    over: snapshot
    into: period

parameters:
  load:
    description: demand to be met
    dims: [snapshot]
  period_weight:
    description: >-
      what one period's costs are worth at the horizon's start — the discount
      that makes a 2040 decision comparable with a 2030 one
    dims: [period]
  opex:
    description: cost of one unit of output
    dims: [generator]
  capex:
    description: cost of holding one unit of capacity through one period
    dims: [generator]
  p_nom_max:
    description: most capacity a generator may build
    dims: [generator]
  activity:
    description: >-
      1 where a generator exists in a period and 0 where it does not — PyPSA
      derives this from a build year and a lifetime, and it decides both what may
      run and what is paid for
    dims: [period, generator]

variables:
  p:
    description: >-
      output of a generator in a snapshot, held at zero in the snapshots whose
      period the generator does not exist in
    foreach: [snapshot, generator]
    bounds:
      lower: 0
  p_nom:
    description: capacity built at a generator
    foreach: [generator]
    bounds:
      lower: 0
      upper: p_nom_max

constraints:
  within_capacity:
    description: >-
      a generator produces no more than the capacity built for it, and nothing at
      all in a period it does not exist in — the activity is read down onto the
      snapshot through the period lookup
    foreach: [snapshot, generator]
    expression: p <= p_nom * at(activity, by=period_of)

  power_balance:
    description: what exists in this snapshot meets the load
    foreach: [snapshot]
    expression: sum(p, over=generator) == load

objective:
  sense: minimize
  description: >-
    operating cost and capacity cost, each discounted by the weight of the
    period it falls in — capacity once per period the asset stands in, which is
    why an asset alive in both is paid for twice
  expression: >-
    p * opex * at(period_weight, by=period_of)
    + p_nom * capex * activity * period_weight
# sources: parameter name -> frame or parquet path
with lps.solve('examples/ports/pypsa_multi_period.yaml', sources) as solution:
    solution.objective  # 85300.0
    solution.dual('power_balance')

The model-building half of examples/ports/references/pypsa/pypsa_multi_period.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``.

    The port's flat ``snapshot`` axis carries a lookup into ``period``; PyPSA
    wants the same fact as a ``(period, timestep)`` MultiIndex, so the snapshots
    are paired with the period each falls in. ``investment_period_weightings``
    takes the port's ``period_weight`` as its ``objective`` column, and ``years``
    is 10 for both periods — set explicitly, because a default of 1 would make
    the decade an accident rather than a statement.
    """
    n = pypsa.Network()
    snapshots: pd.DataFrame = tables['snapshot']
    periods = list(tables['period']['period'])
    n.set_snapshots(pd.MultiIndex.from_arrays([snapshots['period_of'], snapshots['snapshot']]))
    n.investment_periods = periods
    n.investment_period_weightings['years'] = 10
    n.investment_period_weightings['objective'] = tables['period_weight'].set_index('period')['value']

    n.add('Bus', 'hub')
    generators: pd.DataFrame = tables['generator'].set_index('generator')
    n.add(
        'Generator',
        generators.index,
        bus='hub',
        p_nom_extendable=True,
        build_year=[LIFETIMES[g][0] for g in generators.index],
        lifetime=[LIFETIMES[g][1] for g in generators.index],
        p_nom_max=tables['p_nom_max'].set_index('generator')['value'],
        marginal_cost=tables['opex'].set_index('generator')['value'],
        capital_cost=tables['capex'].set_index('generator')['value'],
    )

    load: pd.Series = tables['load'].set_index('snapshot')['value']
    n.add('Load', 'l', bus='hub', p_set=load.to_numpy())
    return n

Capacity is paid for once per period the asset stands in. gas is active in both, so its capital cost enters twice — at 1.0 for 2030 and 0.7 for 2040 — while coal pays once and wind pays once, discounted. That is what p_nom * capex * activity * period_weight says: the term is summed over the (period, generator) pairs the activity table holds, which is PyPSA's weighted_cost loop over investment_period_weightings.objective (optimize.py:322) written as one product.

The other weighting does not reach the objective. investment_period_weightings.years is 10 for both periods here and changes nothing, because capital_cost is given directly rather than annuitised from an overnight_cost (costs.py:119). Worth knowing before reading a PyPSA objective and expecting to find the decade in it — the port carries no years parameter for exactly that reason.

A retired asset is pinned, not absent — the one place the port and PyPSA differ in shape. PyPSA gives coal no dispatch variable in 2040 at all (Generator-p is masked to the active pairs). The port keeps the variable and multiplies its capacity bound by the activity read down through the period lookup, so p <= p_nom * 0 holds it at zero. Same optimum, same duals — a variable pinned to [0, 0] contributes nothing — but not the same model on paper.

Writing the absence exactly would need where: at(activity, by=period_of) on the variable, and a where: cannot call at(): its grammar compares a name against a literal. A second table keyed by (snapshot, generator) would work and would state one fact twice, which is worse. So this rung is a live consumer of #982, which asks whether a mask may read a parameter one declared lookup away — or whether the pin is the answer.

What it exercises

A second axis over time (period) with a lookup down onto the snapshots, a parameter read through that lookup in both a constraint and the objective, and a capacity variable whose cost is summed over the periods it exists in rather than paid once. No construct here is new.