Multi-Period Planning¶
Up to T6 every model spanned a single planning window — a day, a week — and Sizing was the investment tool. Real planning problems span years: maybe demand grows, maybe fuel prices ramp, maybe carbon prices kick in. periods=[...] adds a horizon dimension and lets each period have its own dispatch.
You'll meet:
periods=[...]— extra axis on every variable. Each period shares the timestep structure but has independent dispatch.period_weights=[...]— how much each period contributes to the objective. Default: inferred from period gaps (5-year gaps → weight 5 each). Override for NPV discounting.Sizingshifts meaning — in multi-period mode, the optimizer picks one size per period independently, witheffects_per_sizecharged in each period.
That last point is the key change. In a single-period model, Sizing represented the investment decision. In multi-period, the same Sizing object is per-period — natural for things you re-decide each year:
- Grid connection fee — annual contracted capacity with a utility (kW × €/kW/year).
- Short-term leases — renting equipment by the period.
- Annual tariff brackets — capacity-based fees committed yearly.
For a one-time built asset (real CAPEX paid once, not an annualized rate), use Investment — T8 covers that explicitly.
from datetime import datetime, timedelta
import numpy as np
import pandas as pd
from fluxopt import Carrier, Converter, Effect, Flow, Port, Sizing, optimize
1. Story: a bakery expanding production¶
A bakery runs a heat-driven workshop on natural gas. The plant operations team is locking in a 15-year planning view (2025, 2030, 2035): they are bringing on new ovens and a second shift, so workshop heat demand is set to grow ~50 % over the horizon.
Same gas → boiler → workshop heat system as T6, with three planning periods at five-year gaps. The 24-hour demand shape is the bakery's daily rhythm and stays fixed — its amplitude grows period-to-period as production ramps up.
The natural way to feed period-varying demand into fixed_relative_profile is a pd.DataFrame with timesteps as the index and periods as the columns:
n = 24
timesteps = [datetime(2025, 1, 15) + timedelta(hours=h) for h in range(n)]
periods = [2025, 2030, 2035]
# Fixed daily shape — the bakery's rhythm doesn't change.
base_pattern = np.array(
[10, 10, 8, 8, 8, 12, 25, 60, 70, 75, 75, 70, 65, 60, 55, 50, 45, 40, 30, 25, 20, 15, 12, 10],
dtype=float,
)
# Production ramps: +20 % by 2030, +50 % by 2035.
growth = {2025: 1.0, 2030: 1.2, 2035: 1.5}
time_idx = pd.DatetimeIndex(timesteps, name='time')
demand = pd.DataFrame(
{p: base_pattern * g for p, g in growth.items()},
index=time_idx,
).rename_axis(columns='period')
peak = float(demand.values.max())
profile = demand / peak # values in [0, 1]
demand.head()
| period | 2025 | 2030 | 2035 |
|---|---|---|---|
| time | |||
| 2025-01-15 00:00:00 | 10.0 | 12.0 | 15.0 |
| 2025-01-15 01:00:00 | 10.0 | 12.0 | 15.0 |
| 2025-01-15 02:00:00 | 8.0 | 9.6 | 12.0 |
| 2025-01-15 03:00:00 | 8.0 | 9.6 | 12.0 |
| 2025-01-15 04:00:00 | 8.0 | 9.6 | 12.0 |
def build(**extra):
return optimize(
timesteps=timesteps,
periods=periods,
carriers=[Carrier('gas'), Carrier('heat')],
effects=[Effect('cost', unit='EUR')],
ports=[
Port(
'gas_grid',
imports=[Flow('gas', size=1000, effects_per_flow_hour={'cost': 0.10})],
),
Port(
'workshop',
exports=[Flow('heat', size=peak, fixed_relative_profile=profile)],
),
],
converters=[
Converter.boiler(
'boiler',
thermal_efficiency=0.9,
fuel_flow=Flow('gas', size=300),
thermal_flow=Flow('heat', size=Sizing(min_size=20, max_size=200, effects_per_size={'cost': 1500})),
),
],
objective_effects='cost',
**extra,
)
result = build()
result.effect_totals.to_dataframe(name='total').round(2)
Running HiGHS 1.13.1 (git hash: 1d267d9): Copyright (c) 2026 under MIT licence terms LP linopy-problem-7mf3b6zw has 879 rows; 447 cols; 1392 nonzeros Coefficient ranges: Matrix [1e-01, 2e+03] Cost [5e+00, 5e+00] Bound [2e+02, 2e+02] RHS [8e+00, 1e+03] Presolving model 0 rows, 0 cols, 0 nonzeros 0s 0 rows, 0 cols, 0 nonzeros 0s Presolve reductions: rows 0(-879); columns 0(-447); nonzeros 0(-1392) - Reduced to empty Performed postsolve Solving the original LP from the solution after postsolve Model name : linopy-problem-7mf3b6zw Model status : Optimal Objective value : 2.0830136667e+06 P-D objective error : 5.5887916506e-17 HiGHS run time : 0.00
/home/docs/checkouts/readthedocs.org/user_builds/fluxopt/envs/v0.0.8/lib/python3.13/site-packages/linopy/common.py:492: UserWarning: Coordinates across variables not equal. Perform outer join. warn(
| total | ||
|---|---|---|
| effect | period | |
| cost | 2025 | 112595.33 |
| 2030 | 135114.40 | |
| 2035 | 168893.00 | |
| penalty | 2025 | -0.00 |
| 2030 | -0.00 | |
| 2035 | -0.00 |
effect_totals is now 2-D — effect × period. Per-period cost climbs as production grows: more heat, more gas, more annualized boiler capex.
The boiler's chosen size has a period axis — one size per period:
result.solution['flow--size'].sel(flow='boiler(heat)').to_dataframe(name='size (kW)').round(1)
| flow | size (kW) | |
|---|---|---|
| period | ||
| 2025 | boiler(heat) | 75.0 |
| 2030 | boiler(heat) | 90.0 |
| 2035 | boiler(heat) | 112.5 |
This is the per-period nature of Sizing in action. Each period chooses its own capacity to match its own peak demand: ~75 kW in 2025, ~90 kW in 2030, ~112.5 kW in 2035 — the same +20 %/+50 % growth as the demand. effects_per_size=1500 is charged in each period, so the annualized boiler capex grows in step with capacity.
Contrast with T8 (Investment): one capacity decision spanning the horizon, sized to the peak across all periods, with one-time CAPEX paid in the chosen build period.
# Default weights: inferred from period gaps. 5-year gaps → weight 5 per period.
pd.Series([5, 5, 5], index=pd.Index(periods, name='period'), name='weight (default)')
period 2025 5 2030 5 2035 5 Name: weight (default), dtype: int64
print(f'Objective: {result.objective:.2f} EUR (= sum of weight × per-period cost)')
Objective: 2083013.67 EUR (= sum of weight × per-period cost)
2. NPV weights¶
Future euros are worth less today. Discount each period to present value with a 5 % rate over the five-year span. Override the global weights via period_weights=.
r = 0.05
period_offsets = [0, 5, 10] # year offsets of each period from today
npv_weights = [round(sum(1 / (1 + r) ** (y0 + y) for y in range(5)), 3) for y0 in period_offsets]
result_npv = build(period_weights=npv_weights)
pd.DataFrame(
{
'flat': [5, 5, 5],
'NPV (r=5%)': npv_weights,
'cost/period': result_npv.effect_totals.sel(effect='cost').values.round(2),
},
index=pd.Index(periods, name='period'),
)
Running HiGHS 1.13.1 (git hash: 1d267d9): Copyright (c) 2026 under MIT licence terms LP linopy-problem-zzmc_g43 has 879 rows; 447 cols; 1392 nonzeros Coefficient ranges: Matrix [1e-01, 2e+03] Cost [3e+00, 5e+00] Bound [2e+02, 2e+02] RHS [8e+00, 1e+03] Presolving model 0 rows, 0 cols, 0 nonzeros 0s 0 rows, 0 cols, 0 nonzeros 0s Presolve reductions: rows 0(-879); columns 0(-447); nonzeros 0(-1392) - Reduced to empty Performed postsolve Solving the original LP from the solution after postsolve Model name : linopy-problem-zzmc_g43 Model status : Optimal Objective value : 1.4645162411e+06 P-D objective error : 1.5898121006e-16 HiGHS run time : 0.00
/home/docs/checkouts/readthedocs.org/user_builds/fluxopt/envs/v0.0.8/lib/python3.13/site-packages/linopy/common.py:492: UserWarning: Coordinates across variables not equal. Perform outer join. warn(
| flat | NPV (r=5%) | cost/period | |
|---|---|---|---|
| period | |||
| 2025 | 5 | 4.546 | 112595.33 |
| 2030 | 5 | 3.562 | 135114.40 |
| 2035 | 5 | 2.791 | 168893.00 |
pd.Series(
{
'Flat (default)': round(result.objective, 2),
'NPV (r=5%)': round(result_npv.objective, 2),
},
name='objective (EUR)',
)
Flat (default) 2083013.67 NPV (r=5%) 1464516.24 Name: objective (EUR), dtype: float64
NPV-weighted is lower because future periods are discounted. Per-period sizes don't change between flat and NPV runs — sizing is driven by demand, not by how much each period contributes to the objective. What changes is the total objective value.
Per-effect weights — say physical effects (CO₂, fuel kWh) keep flat weights while only cost is discounted — are configured on each Effect directly via period_weights=. See the API reference.
Recap¶
periods=[...] adds a period axis to every variable. period_weights= (global on optimize(), or per-Effect) controls each period's contribution to the objective.
Sizing in multi-period decides capacity per period independently, charging effects_per_size each period — the right tool when each period has its own annualized cost (grid fees, leases, tariff brackets) and capacity can flex with demand. When you need a single capacity choice that spans the horizon, with one-time CAPEX paid in a chosen build period, that's Investment — next.