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¶
In [1]:
Copied!
import numpy as np
import pandas as pd
import plotly.express as px
import xarray as xr
import flixopt as fx
fx.CONFIG.notebook()
import numpy as np import pandas as pd import plotly.express as px import xarray as xr import flixopt as fx fx.CONFIG.notebook()
Out[1]:
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¶
In [2]:
Copied!
# 3 days, hourly resolution
timesteps = pd.date_range('2024-03-11', periods=72, freq='h')
hours = np.arange(72)
hour_of_day = hours % 24
# Factory operates in shifts:
# - Day shift (6am-2pm): 400 kW
# - Evening shift (2pm-10pm): 350 kW
# - Night (10pm-6am): 80 kW (maintenance heating only)
steam_demand = np.select(
[
(hour_of_day >= 6) & (hour_of_day < 14), # Day shift
(hour_of_day >= 14) & (hour_of_day < 22), # Evening shift
],
[400, 350],
default=80, # Night
)
# Add some variation
np.random.seed(123)
steam_demand = steam_demand + np.random.normal(0, 20, len(steam_demand))
steam_demand = np.clip(steam_demand, 50, 450).astype(float)
print(f'Peak demand: {steam_demand.max():.0f} kW')
print(f'Min demand: {steam_demand.min():.0f} kW')
# 3 days, hourly resolution timesteps = pd.date_range('2024-03-11', periods=72, freq='h') hours = np.arange(72) hour_of_day = hours % 24 # Factory operates in shifts: # - Day shift (6am-2pm): 400 kW # - Evening shift (2pm-10pm): 350 kW # - Night (10pm-6am): 80 kW (maintenance heating only) steam_demand = np.select( [ (hour_of_day >= 6) & (hour_of_day < 14), # Day shift (hour_of_day >= 14) & (hour_of_day < 22), # Evening shift ], [400, 350], default=80, # Night ) # Add some variation np.random.seed(123) steam_demand = steam_demand + np.random.normal(0, 20, len(steam_demand)) steam_demand = np.clip(steam_demand, 50, 450).astype(float) print(f'Peak demand: {steam_demand.max():.0f} kW') print(f'Min demand: {steam_demand.min():.0f} kW')
Peak demand: 435 kW Min demand: 50 kW
In [3]:
Copied!
px.line(x=timesteps, y=steam_demand, title='Factory Steam Demand', labels={'x': 'Time', 'y': 'kW'})
px.line(x=timesteps, y=steam_demand, title='Factory Steam Demand', labels={'x': 'Time', 'y': 'kW'})
Build System with Operational Constraints¶
In [4]:
Copied!
flow_system = fx.FlowSystem(timesteps)
# 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)],
),
)
flow_system = fx.FlowSystem(timesteps) # 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¶
In [5]:
Copied!
flow_system.optimize(fx.solvers.HighsSolver(mip_gap=0.01));
flow_system.optimize(fx.solvers.HighsSolver(mip_gap=0.01));
In [6]:
Copied!
flow_system.statistics.plot.balance('Steam')
flow_system.statistics.plot.balance('Steam')
Out[6]:
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
In [7]:
Copied!
flow_system.statistics.plot.heatmap('MainBoiler(Steam)')
flow_system.statistics.plot.heatmap('MainBoiler(Steam)')
Out[7]:
On/Off Status¶
Track the boiler's operational status:
In [8]:
Copied!
# 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'],
}
)
df = status_ds.to_dataframe().reset_index().melt(id_vars='time', var_name='variable', value_name='value')
fig = px.line(df, x='time', y='value', facet_col='variable', height=300, title='Main Boiler Operation')
fig.update_yaxes(matches=None, showticklabels=True)
fig.for_each_annotation(lambda a: a.update(text=a.text.split('=')[-1]))
fig
# 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'], } ) df = status_ds.to_dataframe().reset_index().melt(id_vars='time', var_name='variable', value_name='value') fig = px.line(df, x='time', y='value', facet_col='variable', height=300, title='Main Boiler Operation') fig.update_yaxes(matches=None, showticklabels=True) fig.for_each_annotation(lambda a: a.update(text=a.text.split('=')[-1])) fig
Startup Count and Costs¶
In [9]:
Copied!
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
print(
f'{total_startups} startups × 50€ = {startup_costs:.0f}€ startup + {gas_costs:.0f}€ gas = {total_costs:.0f}€ total'
)
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 print( f'{total_startups} startups × 50€ = {startup_costs:.0f}€ startup + {gas_costs:.0f}€ gas = {total_costs:.0f}€ total' )
3 startups × 50€ = 150€ startup + 1291€ gas = 1441€ total
Duration Curves¶
See how often each boiler operates at different load levels:
In [10]:
Copied!
flow_system.statistics.plot.duration_curve('MainBoiler(Steam)')
flow_system.statistics.plot.duration_curve('MainBoiler(Steam)')
Out[10]:
In [11]:
Copied!
flow_system.statistics.plot.duration_curve('BackupBoiler(Steam)')
flow_system.statistics.plot.duration_curve('BackupBoiler(Steam)')
Out[11]:
Compare: Without Operational Constraints¶
What if the main boiler had no startup costs or minimum uptime?
In [12]:
Copied!
# Build unconstrained system
fs_unconstrained = fx.FlowSystem(timesteps)
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()
constraint_overhead = (total_costs - unconstrained_costs) / unconstrained_costs * 100
print(f'Constraints add {constraint_overhead:.1f}% cost: {unconstrained_costs:.0f}€ → {total_costs:.0f}€')
# Build unconstrained system fs_unconstrained = fx.FlowSystem(timesteps) 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() constraint_overhead = (total_costs - unconstrained_costs) / unconstrained_costs * 100 print(f'Constraints add {constraint_overhead:.1f}% cost: {unconstrained_costs:.0f}€ → {total_costs:.0f}€')
Constraints add 12.8% cost: 1278€ → 1441€
Energy Flow Sankey¶
A Sankey diagram visualizes the total energy flows through the system:
In [13]:
Copied!
flow_system.statistics.plot.sankey.flows()
flow_system.statistics.plot.sankey.flows()
Out[13]:
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