Multi-Period Optimization¶
Multi-period optimization extends the model with a period dimension (e.g. 2025, 2030, 2035). Each period shares the same timestep structure but can have independent dispatch and sizing decisions.
Period weights scale the objective — they control how much each period "counts". The library provides weight slots; you decide the numbers.
Common choices:
| Strategy | Weights for [2025, 2030, 2035] |
Use case |
|---|---|---|
| Flat (default) | [5, 5, 5] (inferred from gaps) |
Equal importance per year |
| NPV | [4.55, 3.56, 2.79] (discounted) |
Present-value costing |
| Custom | any list[float] |
Scenario-specific |
This notebook shows how to use period_weights_periodic on an Effect
to apply NPV discounting to costs while keeping CO₂ on flat weights.
from datetime import datetime
import numpy as np
import pandas as pd
from fluxopt import Carrier, Converter, Effect, Flow, Port, Sizing, optimize
System¶
A factory needs heat supplied by a gas boiler whose capacity is optimized.
Gas Grid ──► [gas] ──► Boiler η=90% ──► [heat] ──► Factory
0.05 €/kWh demand profile
0.2 kg CO₂/kWh
Three planning periods (2025, 2030, 2035), each representing 5 years. Four representative timesteps per period.
timesteps = [datetime(2025, 1, 15, h) for h in range(6, 10)]
periods = [2025, 2030, 2035]
demand_profile = [0.6, 1.0, 0.8, 0.5] # relative to size=100 kW
Flat weights (default)¶
When no weights are specified, they are inferred from the gaps between
period labels — here [5, 5, 5]. This treats every year equally.
def build_system(**effect_kw):
"""Build the system with configurable Effect parameters."""
return {
'timesteps': timesteps,
'carriers': [Carrier('gas'), Carrier('heat')],
'effects': [
Effect('cost', unit='EUR', is_objective=True, **effect_kw),
Effect('CO2', unit='kg'),
],
'ports': [
Port(
'gas_grid',
imports=[
Flow('gas', size=1000, effects_per_flow_hour={'cost': 0.05, 'CO2': 0.2}),
],
),
Port(
'factory',
exports=[
Flow('heat', size=100, fixed_relative_profile=demand_profile),
],
),
],
'converters': [
Converter.boiler(
'boiler',
thermal_efficiency=0.9,
fuel_flow=Flow('gas', size=Sizing(50, 200, effects_per_size={'cost': 10})),
thermal_flow=Flow('heat', size=200),
)
],
'periods': periods,
}
result_flat = optimize(**build_system())
result_flat.effect_totals.sel(effect='cost').to_pandas()
Running HiGHS 1.13.1 (git hash: 1d267d9): Copyright (c) 2026 under MIT licence terms LP linopy-problem-6fu6pcgy has 186 rows; 114 cols; 315 nonzeros Coefficient ranges: Matrix [5e-02, 1e+01] Cost [1e+00, 5e+00] Bound [2e+02, 2e+02] RHS [5e+01, 1e+03] Presolving model 0 rows, 0 cols, 0 nonzeros 0s 0 rows, 0 cols, 0 nonzeros 0s Presolve reductions: rows 0(-186); columns 0(-114); nonzeros 0(-315) - Reduced to empty Performed postsolve Solving the original LP from the solution after postsolve Model name : linopy-problem-6fu6pcgy Model status : Optimal Objective value : 1.6908333333e+04 P-D objective error : 2.1515256170e-16 HiGHS run time : 0.00
/home/runner/work/fluxopt/fluxopt/.venv/lib/python3.12/site-packages/linopy/common.py:491: UserWarning: Coordinates across variables not equal. Perform outer join. warn(
period 2025 1127.222222 2030 1127.222222 2035 1127.222222 Name: effect--total, dtype: float64
Each period has the same cost (same demand, same system). The objective
sums weight × total across periods:
result_flat.objective # 5 * total + 5 * total + 5 * total
16908.333333333332
NPV weights¶
Future costs are worth less today. To discount them, compute your own
weight factors and pass them via Effect.period_weights_periodic.
With a 5% discount rate and 5-year periods:
r = 0.05
period_offsets = [0, 5, 10]
# annuity sum for each 5-year block, discounted to year 0
npv_weights = [round(sum(1 / (1 + r) ** (y0 + y) for y in range(5)), 2) for y0 in period_offsets]
pd.DataFrame(
{
'period': periods,
'flat (default)': [5, 5, 5],
'NPV (r=5%)': npv_weights,
}
).set_index('period')
| flat (default) | NPV (r=5%) | |
|---|---|---|
| period | ||
| 2025 | 5 | 4.55 |
| 2030 | 5 | 3.56 |
| 2035 | 5 | 2.79 |
Now optimize with NPV weights on cost. CO₂ has no override, so it keeps the global flat weights — physical quantities should not be discounted.
result_npv = optimize(**build_system(period_weights_periodic=npv_weights))
Running HiGHS 1.13.1 (git hash: 1d267d9): Copyright (c) 2026 under MIT licence terms LP linopy-problem-3hq6ab61 has 186 rows; 114 cols; 315 nonzeros Coefficient ranges: Matrix [5e-02, 1e+01] Cost [1e+00, 5e+00] Bound [2e+02, 2e+02] RHS [5e+01, 1e+03] Presolving model 0 rows, 0 cols, 0 nonzeros 0s 0 rows, 0 cols, 0 nonzeros 0s Presolve reductions: rows 0(-186); columns 0(-114); nonzeros 0(-315) - Reduced to empty Performed postsolve Solving the original LP from the solution after postsolve Model name : linopy-problem-3hq6ab61 Model status : Optimal Objective value : 1.2286722222e+04 P-D objective error : 7.4019553429e-17 HiGHS run time : 0.00
/home/runner/work/fluxopt/fluxopt/.venv/lib/python3.12/site-packages/linopy/common.py:491: UserWarning: Coordinates across variables not equal. Perform outer join. warn(
Comparison¶
The per-period costs are identical — only the weighting differs:
totals_flat = result_flat.effect_totals.sel(effect='cost').values
totals_npv = result_npv.effect_totals.sel(effect='cost').values
pd.DataFrame(
{
'cost/period': totals_flat.round(2),
'weight (flat)': [5, 5, 5],
'objective contribution (flat)': (totals_flat * 5).round(2),
'weight (NPV)': npv_weights,
'objective contribution (NPV)': (totals_npv * np.array(npv_weights)).round(2),
},
index=pd.Index(periods, name='period'),
)
| cost/period | weight (flat) | objective contribution (flat) | weight (NPV) | objective contribution (NPV) | |
|---|---|---|---|---|---|
| period | |||||
| 2025 | 1127.22 | 5 | 5636.11 | 4.55 | 5128.86 |
| 2030 | 1127.22 | 5 | 5636.11 | 3.56 | 4012.91 |
| 2035 | 1127.22 | 5 | 5636.11 | 2.79 | 3144.95 |
pd.Series(
{
'Flat (default)': round(result_flat.objective, 2),
'NPV (r=5%)': round(result_npv.objective, 2),
},
name='objective (EUR)',
)
Flat (default) 16908.33 NPV (r=5%) 12286.72 Name: objective (EUR), dtype: float64
How it works¶
The objective function weights each period's total effect:
$$ \min \sum_p \omega^{\text{periodic}}_{k^*,p} \cdot \Phi_{k^*,p} $$
where $\Phi_{k,p}$ combines temporal (dispatch) and periodic (sizing) costs.
| Parameter | Default | Override | Purpose |
|---|---|---|---|
period_weights |
Inferred from gaps | optimize(period_weights=...) |
Global weights for all effects |
period_weights_periodic |
Global weights | Effect(period_weights_periodic=...) |
Per-effect override (e.g. NPV on cost only) |
period_weights_once |
1 (no scaling) |
Effect(period_weights_once=...) |
One-time cost weighting |
The library provides the weight slots — you decide the factors. Flat, NPV, annuity, or any custom scheme.