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¶
Subject to¶
within_capacity
power_balance
Variable domains¶
p
p_nom
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
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.