Constraints¶
Industrial boiler with startup costs, minimum uptime, and load constraints.
This notebook introduces:
- StatusParameters: Model on/off decisions with constraints
- Startup costs: Penalties for turning equipment on
- Minimum uptime/downtime: Prevent rapid cycling
- Minimum load: Equipment can't run below a certain output
Setup¶
import pandas as pd
import xarray as xr
import flixopt as fx
fx.CONFIG.notebook()
flixopt.config.CONFIG
System Description¶
The factory has:
- Industrial boiler: 500 kW capacity, startup cost of 50€, minimum 4h uptime
- Small backup boiler: 100 kW, no startup constraints (always available)
- Steam demand: Varies with production schedule (high during shifts, low overnight)
The main boiler is more efficient but has operational constraints. The backup is less efficient but flexible.
Define Time Horizon and Demand¶
from data.tutorial_data import get_constraints_data
data = get_constraints_data()
timesteps = data['timesteps']
steam_demand = data['steam_demand']
# Visualize the demand with plotly
demand_ds = xr.Dataset(
{
'Steam Demand [kW]': xr.DataArray(steam_demand, dims=['time'], coords={'time': timesteps}),
}
)
demand_ds.plotly.line(x='time', title='Factory Steam Demand')
Build System with Operational Constraints¶
flow_system = fx.FlowSystem(timesteps, name='Constrained')
# Define and register custom carriers
flow_system.add_carriers(
fx.Carrier('gas', '#3498db', 'kW'),
fx.Carrier('steam', '#87CEEB', 'kW_th', 'Process steam'),
)
flow_system.add_elements(
# === Buses ===
fx.Bus('Gas', carrier='gas'),
fx.Bus('Steam', carrier='steam'),
# === Effect ===
fx.Effect('costs', '€', 'Operating Costs', is_standard=True, is_objective=True),
# === Gas Supply ===
fx.Source(
'GasGrid',
outputs=[fx.Flow('Gas', bus='Gas', size=1000, effects_per_flow_hour=0.06)],
),
# === Main Industrial Boiler (with operational constraints) ===
fx.linear_converters.Boiler(
'MainBoiler',
thermal_efficiency=0.94, # High efficiency
# StatusParameters define on/off behavior
status_parameters=fx.StatusParameters(
effects_per_startup={'costs': 50}, # 50€ startup cost
min_uptime=4, # Must run at least 4 hours once started
min_downtime=2, # Must stay off at least 2 hours
),
thermal_flow=fx.Flow(
'Steam',
bus='Steam',
size=500,
relative_minimum=0.3, # Minimum load: 30% = 150 kW
),
fuel_flow=fx.Flow('Gas', bus='Gas', size=600), # Size required for status_parameters
),
# === Backup Boiler (flexible, but less efficient) ===
fx.linear_converters.Boiler(
'BackupBoiler',
thermal_efficiency=0.85, # Lower efficiency
# No status parameters = can turn on/off freely
thermal_flow=fx.Flow('Steam', bus='Steam', size=150),
fuel_flow=fx.Flow('Gas', bus='Gas'),
),
# === Factory Steam Demand ===
fx.Sink(
'Factory',
inputs=[fx.Flow('Steam', bus='Steam', size=1, fixed_relative_profile=steam_demand)],
),
)
Run Optimization¶
flow_system.optimize(fx.solvers.HighsSolver(mip_gap=0.01));
flow_system.stats.plot.balance('Steam')
Main Boiler Operation¶
Notice how the main boiler:
- Runs continuously during production (respecting min uptime)
- Stays above minimum load (30%)
- Shuts down during low-demand periods
flow_system.stats.plot.heatmap('MainBoiler(Steam)')
On/Off Status¶
Track the boiler's operational status:
# Merge solution DataArrays directly - xarray aligns coordinates automatically
status_ds = xr.Dataset(
{
'Status': flow_system.solution['MainBoiler|status'],
'Steam Production [kW]': flow_system.solution['MainBoiler(Steam)|flow_rate'],
}
)
status_ds.plotly.line(x='time', title='Main Boiler Operation', height=300)
Startup Count and Costs¶
total_startups = int(flow_system.solution['MainBoiler|startup'].sum().item())
total_costs = flow_system.solution['costs'].item()
startup_costs = total_startups * 50
gas_costs = total_costs - startup_costs
pd.DataFrame(
{
'Startups': {'Count': total_startups, 'EUR': startup_costs},
'Gas': {'Count': '-', 'EUR': gas_costs},
'Total': {'Count': '-', 'EUR': total_costs},
}
)
| Startups | Gas | Total | |
|---|---|---|---|
| Count | 3 | - | - |
| EUR | 150 | 1291.320174 | 1441.320174 |
Duration Curves¶
See how often each boiler operates at different load levels:
flow_system.stats.plot.duration_curve('MainBoiler(Steam)')
flow_system.stats.plot.duration_curve('BackupBoiler(Steam)')
Compare: Without Operational Constraints¶
What if the main boiler had no startup costs or minimum uptime?
# Build unconstrained system
fs_unconstrained = fx.FlowSystem(timesteps, name='Unconstrained')
fs_unconstrained.add_carriers(
fx.Carrier('gas', '#3498db', 'kW'),
fx.Carrier('steam', '#87CEEB', 'kW_th', 'Process steam'),
)
fs_unconstrained.add_elements(
fx.Bus('Gas', carrier='gas'),
fx.Bus('Steam', carrier='steam'),
fx.Effect('costs', '€', 'Operating Costs', is_standard=True, is_objective=True),
fx.Source('GasGrid', outputs=[fx.Flow('Gas', bus='Gas', size=1000, effects_per_flow_hour=0.06)]),
# Main boiler WITHOUT status parameters
fx.linear_converters.Boiler(
'MainBoiler',
thermal_efficiency=0.94,
thermal_flow=fx.Flow('Steam', bus='Steam', size=500),
fuel_flow=fx.Flow('Gas', bus='Gas'),
),
fx.linear_converters.Boiler(
'BackupBoiler',
thermal_efficiency=0.85,
thermal_flow=fx.Flow('Steam', bus='Steam', size=150),
fuel_flow=fx.Flow('Gas', bus='Gas'),
),
fx.Sink('Factory', inputs=[fx.Flow('Steam', bus='Steam', size=1, fixed_relative_profile=steam_demand)]),
)
fs_unconstrained.optimize(fx.solvers.HighsSolver())
unconstrained_costs = fs_unconstrained.solution['costs'].item()
pd.DataFrame(
{
'Without Constraints': {'Cost [EUR]': unconstrained_costs},
'With Constraints': {'Cost [EUR]': total_costs},
'Overhead': {
'Cost [EUR]': total_costs - unconstrained_costs,
'%': (total_costs - unconstrained_costs) / unconstrained_costs * 100,
},
}
)
| Without Constraints | With Constraints | Overhead | |
|---|---|---|---|
| Cost [EUR] | 1278.227071 | 1441.320174 | 163.093102 |
| % | NaN | NaN | 12.759322 |
Side-by-Side Comparison¶
Use the Comparison class to visualize both systems together:
comp = fx.Comparison([fs_unconstrained, flow_system])
comp.stats.plot.effects()
Energy Flow Sankey¶
A Sankey diagram visualizes the total energy flows through the system:
flow_system.stats.plot.sankey.flows()
Custom Constraints¶
Sometimes you need constraints beyond what's built into the components. The before_solve callback lets you add custom constraints directly to the optimization model.
Example: Ramp Rate Limits¶
Large boilers can't change output instantly—thermal stress limits how fast they can ramp up or down. Let's add a constraint limiting the main boiler to ±50 kW change per timestep:
fs_ramp = flow_system.copy()
def add_ramp_rate_limit(fs, max_ramp: float = 10):
"""Limit ramp rate when boiler stays on. Uses Big-M to allow on/off jumps."""
model = fs.model
flow = model.variables['MainBoiler(Steam)|flow_rate']
status = model.variables['MainBoiler|status']
ramp = flow - flow.shift(time=1)
both_on = status + status.shift(time=1) # =2 when both on, <2 otherwise
big_m = 500 # Big-M (larger than max flow)
model.add_constraints(ramp <= max_ramp + big_m * (2 - both_on), name='ramp_up')
model.add_constraints(ramp >= -max_ramp - big_m * (2 - both_on), name='ramp_down')
fs_ramp.optimize(fx.solvers.HighsSolver(mip_gap=0.01), before_solve=add_ramp_rate_limit);
# Compare: with vs without ramp rate limits
comparison_ds = xr.Dataset(
{
'Without ramp limit': flow_system.solution['MainBoiler(Steam)|flow_rate'],
'With ramp limit (±10 kW)': fs_ramp.solution['MainBoiler(Steam)|flow_rate'],
}
)
comparison_ds.plotly.line(x='time', title='Main Boiler Output: Effect of Ramp Rate Limits', height=350)
Finding Available Variables¶
To discover what variables you can use in custom constraints, inspect the model after building:
# Calculate actual ramp rates (change between timesteps)
flow_original = flow_system.solution['MainBoiler(Steam)|flow_rate']
flow_ramp = fs_ramp.solution['MainBoiler(Steam)|flow_rate']
ramp_original = flow_original.diff('time')
ramp_limited = flow_ramp.diff('time')
print(f'Without ramp limit: max ramp = {abs(ramp_original).max().item():.1f} kW/step')
print(f'With ramp limit: max ramp = {abs(ramp_limited).max().item():.1f} kW/step (limit: 50 kW)')
# Show the ramp rates over time
ramp_ds = xr.Dataset(
{
'Original ramp rate': ramp_original,
'Limited ramp rate': ramp_limited,
}
)
ramp_ds.plotly.line(x='time', title='Ramp Rates (kW change per timestep)', height=300)
Without ramp limit: max ramp = 400.6 kW/step With ramp limit: max ramp = 400.6 kW/step (limit: 50 kW)
Finding Available Variables¶
To discover what variables you can use in custom constraints, inspect the model after building:
# List all variables in the model
print('Available variables:')
for name in fs_ramp.model.variables:
print(f' {name}')
Available variables: costs(periodic) costs(temporal) costs(temporal)|per_timestep costs Penalty(periodic) Penalty(temporal) Penalty(temporal)|per_timestep Penalty GasGrid(Gas)|flow_rate GasGrid(Gas)|total_flow_hours GasGrid(Gas)->costs(temporal) MainBoiler(Gas)|flow_rate MainBoiler(Gas)|status MainBoiler(Gas)|active_hours MainBoiler(Gas)|total_flow_hours MainBoiler(Steam)|flow_rate MainBoiler(Steam)|status MainBoiler(Steam)|active_hours MainBoiler(Steam)|total_flow_hours MainBoiler|status MainBoiler|inactive MainBoiler|active_hours MainBoiler|startup MainBoiler|shutdown MainBoiler|uptime MainBoiler|downtime MainBoiler->costs(temporal) BackupBoiler(Gas)|flow_rate BackupBoiler(Gas)|total_flow_hours BackupBoiler(Steam)|flow_rate BackupBoiler(Steam)|total_flow_hours Factory(Steam)|flow_rate Factory(Steam)|total_flow_hours
Key Concepts¶
StatusParameters Options¶
fx.StatusParameters(
# Startup/shutdown costs
effects_per_startup={'costs': 50}, # Cost per startup event
effects_per_shutdown={'costs': 10}, # Cost per shutdown event
# Time constraints
min_uptime=4, # Minimum hours running once started
min_downtime=2, # Minimum hours off once stopped
# Startup limits
max_startups=10, # Maximum startups per period
)
Minimum Load¶
Set via Flow.relative_minimum:
fx.Flow('Steam', bus='Steam', size=500, relative_minimum=0.3) # Min 30% load
When Status is Active¶
- When
StatusParametersis set, a binary on/off variable is created - Flow is zero when status=0, within bounds when status=1
- Without
StatusParameters, flow can vary continuously from 0 to max
Summary¶
You learned how to:
- Add startup costs with
effects_per_startup - Set minimum run times with
min_uptimeandmin_downtime - Define minimum load with
relative_minimum - Access status variables from the solution
- Use duration curves to analyze operation patterns
Next Steps¶
- 05-multi-carrier-system: Model CHP with electricity and heat
- 06a-time-varying-parameters: Variable efficiency based on external conditions