Defining Asset Operational Limits over User-Defined Time Periods¶
This example demonstrates how to impose periodic minimum and maximum energy output constraints on a Combined Cycle Gas Turbine (CCGT) generator using PyPSA's custom constraint mechanism. This example focusses on monthly limits but the approach would work for limits set over any user-defined periods, including non-uniform ones (e.g, daily, weekly, every reporting period, etc.).
It uses a 3-hourly resolved variant of the single-node network developed in the single-node capacity expansion example, replacing the load-shedding generator with a combined-cycle gas turbine (CCGT).
In practice, monthly CCGT constraints arise from:
- contractual minimum-offtake agreements (lower bound),
- fuel-supply or CO₂-permit limits (upper bound).
Our operational limits will be added via the extra_functionality callback in n.optimize().
import calendar
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import xarray as xr
First, we will create the definition for our new CCGT generator, and use it in our network in place of load shedding.
We attach the monthly limits to the network as custom time-dependent attributes (p_min_periodic, p_max_periodic), with values only on the first snapshot of each month (all other snapshots are NaN).
PyPSA stores any extra keyword arguments passed to n.add() as component attributes, so these custom attributes persist when exporting the network to file / importing the network from file.
If we wanted to apply a different period for our limit, we would update the attribute to have the limit data applied at the first snapshot of each period, e.g.:
daily_limit = pd.Series([10, 15, ...], index=pd.to_datetime(["2019-01-01", "2019-01-02", ...]))
# We have to expand the data out to all snapshots to be able to store it as a PyPSA network attribute.
# All snapshots that aren't the first of the day will be filled with NaN.
daily_limit_all_snapshots = daily_limit.reindex(n.snapshots)
n.add(
"Generator",
"CCGT",
...
p_max_periodic=daily_limit_all_snapshots,
)
def get_p_monthly(network: pypsa.Network, output_mw: float) -> pd.Series:
"""Define a monthly output limit for dispatch, set for the first snapshot of each month."""
sns = network.snapshots
weights = network.snapshot_weightings["generators"]
return pd.Series(output_mw * weights, index=sns).resample("MS").sum().reindex(sns)
n = pypsa.examples.model_energy()
ccgt_p_nom = 2000 # MW – fixed installed capacity
min_fraction = 0.15 # lower bound: 15 % of maximum possible monthly output
max_fraction = 0.40 # upper bound: 40 % of maximum possible monthly output
p_min_monthly = get_p_monthly(n, min_fraction * ccgt_p_nom)
p_max_monthly = get_p_monthly(n, max_fraction * ccgt_p_nom)
# Replace load shedding with a fixed-capacity CCGT
n.remove("Generator", "load shedding")
n.add(
"Generator",
"CCGT",
bus="electricity",
carrier="CCGT",
p_nom=ccgt_p_nom,
marginal_cost=60, # €/MWh
p_nom_extendable=False,
p_min_periodic=p_min_monthly,
p_max_periodic=p_max_monthly,
)
n.add("Carrier", "CCGT")
monthly_bounds = (
pd.concat([p_min_monthly, p_max_monthly], axis=1, keys=["p_min", "p_max"])
.groupby(n.snapshots.month.rename("month"))
.sum()
)
display(monthly_bounds)
INFO:pypsa.network.io:Imported network 'Model-Energy' has buses, carriers, generators, links, loads, storage_units, stores
| p_min | p_max | |
|---|---|---|
| month | ||
| 1 | 223200.0 | 595200.0 |
| 2 | 201600.0 | 537600.0 |
| 3 | 223200.0 | 595200.0 |
| 4 | 216000.0 | 576000.0 |
| 5 | 223200.0 | 595200.0 |
| 6 | 216000.0 | 576000.0 |
| 7 | 223200.0 | 595200.0 |
| 8 | 223200.0 | 595200.0 |
| 9 | 216000.0 | 576000.0 |
| 10 | 223200.0 | 595200.0 |
| 11 | 216000.0 | 576000.0 |
| 12 | 223200.0 | 595200.0 |
Apply Custom Constraints¶
The extra_functionality callback receives the network n and the active snapshot index sns.
Inside the callback we:
Retrieve the CCGT active-power variable from the Linopy model (
n.model).Sum the variable over all (weighted) snapshots in a period (here, months): $$ E_m = \sum_{t \in \text{month } m} w_t \cdot p_{\text{CCGT},t} $$ where $w_t$ is the snapshot weighting (1 h for hourly data).
Add two constraints per month: $$ E_m \geq E_m^{\min}, \quad E_m \leq E_m^{\max} $$
def get_grouper(limit_attr: xr.DataArray) -> xr.DataArray:
"""Return a grouper for snapshots in the same period based on the NaN values between each non-NaN value in the limit attribute."""
return limit_attr.notnull().cumsum("snapshot").rename("limit_period")
def period_ccgt_output_constraints(n: pypsa.Network, sns: pd.Index) -> None:
"""Add lower and upper energy-output constraints for the CCGT dispatch per period.
Parameters
----------
n : pypsa.Network
sns : pd.Index
Active snapshots passed by PyPSA during optimisation.
"""
m = n.model
selector = {"name": "CCGT", "snapshot": sns}
p_ccgt = m.variables["Generator-p"].sel(**selector)
weights = n.snapshot_weightings["generators"].loc[sns]
for limit, sign in {"max": "<=", "min": ">="}.items():
limit_attr = n.components["Generator"].da[f"p_{limit}_periodic"].sel(**selector)
grouper = get_grouper(limit_attr)
p_period = (p_ccgt * weights).groupby(grouper).sum()
p_lim_period = limit_attr.groupby(grouper).sum()
m.add_constraints(
lhs=p_period, sign=sign, rhs=p_lim_period, name=f"CCGT-period-{limit}"
)
Run Optimization¶
We solve the network twice:
- Baseline – no custom constraints, purely cost-optimal dispatch.
- Constrained – the
extra_functionalitycallback enforces monthly CCGT bounds.
Both runs use the default HiGHS solver.
# --- Baseline: no custom constraints ---
n_base = n.copy()
n_base.optimize(include_objective_constant=False)
print("Baseline solved")
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io:Writing objective.
Writing constraints.: 0%| | 0/21 [00:00<?, ?it/s]
Writing constraints.: 90%|█████████ | 19/21 [00:00<00:00, 184.56it/s]
Writing constraints.: 100%|██████████| 21/21 [00:00<00:00, 179.41it/s]
Writing continuous variables.: 0%| | 0/11 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 11/11 [00:00<00:00, 395.86it/s]
INFO:linopy.io: Writing time: 0.16s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 29206 primals, 64246 duals Objective: 6.83e+09 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Generator-ext-p-lower, Generator-ext-p-upper, Link-ext-p-lower, Link-ext-p-upper, Store-ext-e-lower, Store-ext-e-upper, StorageUnit-ext-p_dispatch-lower, StorageUnit-ext-p_dispatch-upper, StorageUnit-ext-p_store-lower, StorageUnit-ext-p_store-upper, StorageUnit-ext-state_of_charge-lower, StorageUnit-ext-state_of_charge-upper, StorageUnit-energy_balance, Store-energy_balance were not assigned to the network.
Baseline solved
# --- Constrained: with monthly CCGT output limits ---
n_constrained = n.copy()
n_constrained.optimize(
extra_functionality=period_ccgt_output_constraints,
include_objective_constant=False,
)
print("Constrained solve completed")
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io:Writing objective.
Writing constraints.: 0%| | 0/23 [00:00<?, ?it/s]
Writing constraints.: 87%|████████▋ | 20/23 [00:00<00:00, 198.69it/s]
Writing constraints.: 100%|██████████| 23/23 [00:00<00:00, 197.90it/s]
Writing continuous variables.: 0%| | 0/11 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 11/11 [00:00<00:00, 398.77it/s]
INFO:linopy.io: Writing time: 0.15s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 29206 primals, 64270 duals Objective: 6.94e+09 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Generator-ext-p-lower, Generator-ext-p-upper, Link-ext-p-lower, Link-ext-p-upper, Store-ext-e-lower, Store-ext-e-upper, StorageUnit-ext-p_dispatch-lower, StorageUnit-ext-p_dispatch-upper, StorageUnit-ext-p_store-lower, StorageUnit-ext-p_store-upper, StorageUnit-ext-state_of_charge-lower, StorageUnit-ext-state_of_charge-upper, StorageUnit-energy_balance, Store-energy_balance, CCGT-period-max, CCGT-period-min were not assigned to the network.
Constrained solve completed
We can view the constraints that has been built by querying linopy directy:
display(n_constrained.model.constraints["CCGT-period-min"])
display(n_constrained.model.constraints["CCGT-period-max"])
Constraint `CCGT-period-min` [limit_period: 12]: ------------------------------------------------ [1]: +3 Generator-p[2019-01-01 00:00:00, CCGT] + 3 Generator-p[2019-01-01 03:00:00, CCGT] + 3 Generator-p[2019-01-01 06:00:00, CCGT] ... +3 Generator-p[2019-01-31 15:00:00, CCGT] + 3 Generator-p[2019-01-31 18:00:00, CCGT] + 3 Generator-p[2019-01-31 21:00:00, CCGT] ≥ 223200.0 [2]: +3 Generator-p[2019-02-01 00:00:00, CCGT] + 3 Generator-p[2019-02-01 03:00:00, CCGT] + 3 Generator-p[2019-02-01 06:00:00, CCGT] ... +3 Generator-p[2019-02-28 15:00:00, CCGT] + 3 Generator-p[2019-02-28 18:00:00, CCGT] + 3 Generator-p[2019-02-28 21:00:00, CCGT] ≥ 201600.0 [3]: +3 Generator-p[2019-03-01 00:00:00, CCGT] + 3 Generator-p[2019-03-01 03:00:00, CCGT] + 3 Generator-p[2019-03-01 06:00:00, CCGT] ... +3 Generator-p[2019-03-31 15:00:00, CCGT] + 3 Generator-p[2019-03-31 18:00:00, CCGT] + 3 Generator-p[2019-03-31 21:00:00, CCGT] ≥ 223200.0 [4]: +3 Generator-p[2019-04-01 00:00:00, CCGT] + 3 Generator-p[2019-04-01 03:00:00, CCGT] + 3 Generator-p[2019-04-01 06:00:00, CCGT] ... +3 Generator-p[2019-04-30 15:00:00, CCGT] + 3 Generator-p[2019-04-30 18:00:00, CCGT] + 3 Generator-p[2019-04-30 21:00:00, CCGT] ≥ 216000.0 [5]: +3 Generator-p[2019-05-01 00:00:00, CCGT] + 3 Generator-p[2019-05-01 03:00:00, CCGT] + 3 Generator-p[2019-05-01 06:00:00, CCGT] ... +3 Generator-p[2019-05-31 15:00:00, CCGT] + 3 Generator-p[2019-05-31 18:00:00, CCGT] + 3 Generator-p[2019-05-31 21:00:00, CCGT] ≥ 223200.0 [6]: +3 Generator-p[2019-06-01 00:00:00, CCGT] + 3 Generator-p[2019-06-01 03:00:00, CCGT] + 3 Generator-p[2019-06-01 06:00:00, CCGT] ... +3 Generator-p[2019-06-30 15:00:00, CCGT] + 3 Generator-p[2019-06-30 18:00:00, CCGT] + 3 Generator-p[2019-06-30 21:00:00, CCGT] ≥ 216000.0 [7]: +3 Generator-p[2019-07-01 00:00:00, CCGT] + 3 Generator-p[2019-07-01 03:00:00, CCGT] + 3 Generator-p[2019-07-01 06:00:00, CCGT] ... +3 Generator-p[2019-07-31 15:00:00, CCGT] + 3 Generator-p[2019-07-31 18:00:00, CCGT] + 3 Generator-p[2019-07-31 21:00:00, CCGT] ≥ 223200.0 [8]: +3 Generator-p[2019-08-01 00:00:00, CCGT] + 3 Generator-p[2019-08-01 03:00:00, CCGT] + 3 Generator-p[2019-08-01 06:00:00, CCGT] ... +3 Generator-p[2019-08-31 15:00:00, CCGT] + 3 Generator-p[2019-08-31 18:00:00, CCGT] + 3 Generator-p[2019-08-31 21:00:00, CCGT] ≥ 223200.0 [9]: +3 Generator-p[2019-09-01 00:00:00, CCGT] + 3 Generator-p[2019-09-01 03:00:00, CCGT] + 3 Generator-p[2019-09-01 06:00:00, CCGT] ... +3 Generator-p[2019-09-30 15:00:00, CCGT] + 3 Generator-p[2019-09-30 18:00:00, CCGT] + 3 Generator-p[2019-09-30 21:00:00, CCGT] ≥ 216000.0 [10]: +3 Generator-p[2019-10-01 00:00:00, CCGT] + 3 Generator-p[2019-10-01 03:00:00, CCGT] + 3 Generator-p[2019-10-01 06:00:00, CCGT] ... +3 Generator-p[2019-10-31 15:00:00, CCGT] + 3 Generator-p[2019-10-31 18:00:00, CCGT] + 3 Generator-p[2019-10-31 21:00:00, CCGT] ≥ 223200.0 [11]: +3 Generator-p[2019-11-01 00:00:00, CCGT] + 3 Generator-p[2019-11-01 03:00:00, CCGT] + 3 Generator-p[2019-11-01 06:00:00, CCGT] ... +3 Generator-p[2019-11-30 15:00:00, CCGT] + 3 Generator-p[2019-11-30 18:00:00, CCGT] + 3 Generator-p[2019-11-30 21:00:00, CCGT] ≥ 216000.0 [12]: +3 Generator-p[2019-12-01 00:00:00, CCGT] + 3 Generator-p[2019-12-01 03:00:00, CCGT] + 3 Generator-p[2019-12-01 06:00:00, CCGT] ... +3 Generator-p[2019-12-31 15:00:00, CCGT] + 3 Generator-p[2019-12-31 18:00:00, CCGT] + 3 Generator-p[2019-12-31 21:00:00, CCGT] ≥ 223200.0
Constraint `CCGT-period-max` [limit_period: 12]: ------------------------------------------------ [1]: +3 Generator-p[2019-01-01 00:00:00, CCGT] + 3 Generator-p[2019-01-01 03:00:00, CCGT] + 3 Generator-p[2019-01-01 06:00:00, CCGT] ... +3 Generator-p[2019-01-31 15:00:00, CCGT] + 3 Generator-p[2019-01-31 18:00:00, CCGT] + 3 Generator-p[2019-01-31 21:00:00, CCGT] ≤ 595200.0 [2]: +3 Generator-p[2019-02-01 00:00:00, CCGT] + 3 Generator-p[2019-02-01 03:00:00, CCGT] + 3 Generator-p[2019-02-01 06:00:00, CCGT] ... +3 Generator-p[2019-02-28 15:00:00, CCGT] + 3 Generator-p[2019-02-28 18:00:00, CCGT] + 3 Generator-p[2019-02-28 21:00:00, CCGT] ≤ 537600.0 [3]: +3 Generator-p[2019-03-01 00:00:00, CCGT] + 3 Generator-p[2019-03-01 03:00:00, CCGT] + 3 Generator-p[2019-03-01 06:00:00, CCGT] ... +3 Generator-p[2019-03-31 15:00:00, CCGT] + 3 Generator-p[2019-03-31 18:00:00, CCGT] + 3 Generator-p[2019-03-31 21:00:00, CCGT] ≤ 595200.0 [4]: +3 Generator-p[2019-04-01 00:00:00, CCGT] + 3 Generator-p[2019-04-01 03:00:00, CCGT] + 3 Generator-p[2019-04-01 06:00:00, CCGT] ... +3 Generator-p[2019-04-30 15:00:00, CCGT] + 3 Generator-p[2019-04-30 18:00:00, CCGT] + 3 Generator-p[2019-04-30 21:00:00, CCGT] ≤ 576000.0 [5]: +3 Generator-p[2019-05-01 00:00:00, CCGT] + 3 Generator-p[2019-05-01 03:00:00, CCGT] + 3 Generator-p[2019-05-01 06:00:00, CCGT] ... +3 Generator-p[2019-05-31 15:00:00, CCGT] + 3 Generator-p[2019-05-31 18:00:00, CCGT] + 3 Generator-p[2019-05-31 21:00:00, CCGT] ≤ 595200.0 [6]: +3 Generator-p[2019-06-01 00:00:00, CCGT] + 3 Generator-p[2019-06-01 03:00:00, CCGT] + 3 Generator-p[2019-06-01 06:00:00, CCGT] ... +3 Generator-p[2019-06-30 15:00:00, CCGT] + 3 Generator-p[2019-06-30 18:00:00, CCGT] + 3 Generator-p[2019-06-30 21:00:00, CCGT] ≤ 576000.0 [7]: +3 Generator-p[2019-07-01 00:00:00, CCGT] + 3 Generator-p[2019-07-01 03:00:00, CCGT] + 3 Generator-p[2019-07-01 06:00:00, CCGT] ... +3 Generator-p[2019-07-31 15:00:00, CCGT] + 3 Generator-p[2019-07-31 18:00:00, CCGT] + 3 Generator-p[2019-07-31 21:00:00, CCGT] ≤ 595200.0 [8]: +3 Generator-p[2019-08-01 00:00:00, CCGT] + 3 Generator-p[2019-08-01 03:00:00, CCGT] + 3 Generator-p[2019-08-01 06:00:00, CCGT] ... +3 Generator-p[2019-08-31 15:00:00, CCGT] + 3 Generator-p[2019-08-31 18:00:00, CCGT] + 3 Generator-p[2019-08-31 21:00:00, CCGT] ≤ 595200.0 [9]: +3 Generator-p[2019-09-01 00:00:00, CCGT] + 3 Generator-p[2019-09-01 03:00:00, CCGT] + 3 Generator-p[2019-09-01 06:00:00, CCGT] ... +3 Generator-p[2019-09-30 15:00:00, CCGT] + 3 Generator-p[2019-09-30 18:00:00, CCGT] + 3 Generator-p[2019-09-30 21:00:00, CCGT] ≤ 576000.0 [10]: +3 Generator-p[2019-10-01 00:00:00, CCGT] + 3 Generator-p[2019-10-01 03:00:00, CCGT] + 3 Generator-p[2019-10-01 06:00:00, CCGT] ... +3 Generator-p[2019-10-31 15:00:00, CCGT] + 3 Generator-p[2019-10-31 18:00:00, CCGT] + 3 Generator-p[2019-10-31 21:00:00, CCGT] ≤ 595200.0 [11]: +3 Generator-p[2019-11-01 00:00:00, CCGT] + 3 Generator-p[2019-11-01 03:00:00, CCGT] + 3 Generator-p[2019-11-01 06:00:00, CCGT] ... +3 Generator-p[2019-11-30 15:00:00, CCGT] + 3 Generator-p[2019-11-30 18:00:00, CCGT] + 3 Generator-p[2019-11-30 21:00:00, CCGT] ≤ 576000.0 [12]: +3 Generator-p[2019-12-01 00:00:00, CCGT] + 3 Generator-p[2019-12-01 03:00:00, CCGT] + 3 Generator-p[2019-12-01 06:00:00, CCGT] ... +3 Generator-p[2019-12-31 15:00:00, CCGT] + 3 Generator-p[2019-12-31 18:00:00, CCGT] + 3 Generator-p[2019-12-31 21:00:00, CCGT] ≤ 595200.0
def periodic_energy_balance(
network: pypsa.Network | pypsa.NetworkCollection, grouper: pd.Series
) -> pd.Series:
"""Return periodic energy output [MWh] for a solved network.
The `grouper` defines which snapshots belong to the same period, e.g. by month or by season.
"""
weights = network.snapshot_weightings.generators
energy_balance = network.statistics.energy_balance(
groupby_time=False, bus_carrier="electricity"
).droplevel(["component", "bus_carrier"])
energy_balance_period = (energy_balance * weights).T.groupby(grouper).sum()
return energy_balance_period
def discharge_capacity(network: pypsa.Network | pypsa.NetworkCollection) -> pd.Series:
"""Return discharge capacity [MW] for a solved network."""
generators = network.generators.p_nom_opt
storage_units = network.storage_units.p_nom_opt
capacity = pd.concat([generators, storage_units])
return capacity
nc = pypsa.NetworkCollection({"Baseline": n_base, "Constrained": n_constrained})
grouper = get_grouper(
n_constrained.components["Generator"].da["p_min_periodic"].sel(name="CCGT")
).to_series()
monthly_energy_balance = periodic_energy_balance(nc, grouper).xs(
"CCGT", level="carrier", axis=1
)
month_labels = [calendar.month_abbr[m] for m in monthly_bounds.index]
df_plot = (
monthly_energy_balance.stack().to_frame("Monthly CCGT dispatch (MWh)").reset_index()
)
df_plot["Month"] = df_plot["limit_period"].apply(lambda m: calendar.month_abbr[m])
fig = px.bar(
df_plot,
x="Month",
y="Monthly CCGT dispatch (MWh)",
color="network",
barmode="group",
)
# Overlay the monthly bounds to verify that CCGT dispatch is within the limits
common_attrs = {
"x": month_labels,
"mode": "lines",
"legendgroup": "bound",
"line": {"width": 1, "shape": "hvh", "color": "black"},
}
fig.add_trace(go.Scatter(y=monthly_bounds["p_min"], showlegend=False, **common_attrs))
fig.add_trace(
go.Scatter(
y=monthly_bounds["p_max"],
name="Feasible constrained range",
fill="tonexty",
fillcolor="rgba(169, 169, 169, 0.3)",
**common_attrs,
)
)
Monthly generation breakdown¶
Since the segmented network uses non-uniform snapshot durations, we aggregate by computing the weighted energy (p × snapshot_weighting) per technology per month. This gives total-generation [MWh] for each month and shows how the constraints redistribute generation between wind/solar, hydrogen, and the CCGT.
capacity = discharge_capacity(nc)
df_plot = capacity.to_frame("Capacity (MW)").reset_index()
fig_capacity = px.bar(
df_plot,
x="name",
y="Capacity (MW)",
color="network",
barmode="group",
)
fig_capacity.update_layout(
xaxis_title="Generator / Storage unit", yaxis_title="Installed capacity (MW)"
)
System cost comparison¶
The monthly energy bounds force the CCGT away from its cost-optimal dispatch level. We compare the total system operating cost for both runs.
with pd.option_context("display.float_format", "{:,.0f} M€".format):
capex = nc.statistics.capex().groupby("network").sum() / 1e6
opex = nc.statistics.opex().groupby("network").sum() / 1e6
total = capex + opex
cost_df = pd.concat([capex, opex, total], axis=1, keys=["Capex", "Opex", "Total"]).T
cost_df["Cost delta"] = (
cost_df["Constrained"]
.subtract(cost_df["Baseline"])
.div(cost_df["Baseline"])
.mul(100)
.round(1)
.astype(str)
+ " %"
)
display(cost_df)
| network | Baseline | Constrained | Cost delta |
|---|---|---|---|
| Capex | 6,541 M€ | 6,654 M€ | 1.7 % |
| Opex | 284 M€ | 286 M€ | 0.6 % |
| Total | 6,825 M€ | 6,940 M€ | 1.7 % |