Scenarios¶
Multi-year planning with uncertain demand scenarios.
This notebook introduces:
- Periods: Multiple planning years with different conditions
- Scenarios: Uncertain futures (mild vs. harsh winter)
- Scenario weights: Probability-weighted optimization
- Multi-dimensional data: Parameters that vary by time, period, and scenario
Setup¶
In [1]:
Copied!
import numpy as np
import pandas as pd
import plotly.express as px
import flixopt as fx
fx.CONFIG.notebook()
import numpy as np import pandas as pd import plotly.express as px import flixopt as fx fx.CONFIG.notebook()
Out[1]:
flixopt.config.CONFIG
The Planning Problem¶
We're designing a heating system with:
- 3 periods (years): 2024, 2025, 2026 - gas prices expected to rise
- 2 scenarios: "Mild Winter" (60% probability) and "Harsh Winter" (40% probability)
- Investment decision: Size of CHP unit (made once, works across all futures)
The optimizer finds the investment that minimizes expected cost across all scenarios.
Define Dimensions¶
In [2]:
Copied!
# Time horizon: one representative winter week
timesteps = pd.date_range('2024-01-15', periods=168, freq='h') # 7 days
# Planning periods (years)
periods = pd.Index([2024, 2025, 2026], name='period')
# Scenarios with probabilities
scenarios = pd.Index(['Mild Winter', 'Harsh Winter'], name='scenario')
scenario_weights = np.array([0.6, 0.4]) # 60% mild, 40% harsh
print(f'Time dimension: {len(timesteps)} hours')
print(f'Periods: {list(periods)}')
print(f'Scenarios: {list(scenarios)}')
print(f'Scenario weights: {dict(zip(scenarios, scenario_weights, strict=False))}')
# Time horizon: one representative winter week timesteps = pd.date_range('2024-01-15', periods=168, freq='h') # 7 days # Planning periods (years) periods = pd.Index([2024, 2025, 2026], name='period') # Scenarios with probabilities scenarios = pd.Index(['Mild Winter', 'Harsh Winter'], name='scenario') scenario_weights = np.array([0.6, 0.4]) # 60% mild, 40% harsh print(f'Time dimension: {len(timesteps)} hours') print(f'Periods: {list(periods)}') print(f'Scenarios: {list(scenarios)}') print(f'Scenario weights: {dict(zip(scenarios, scenario_weights, strict=False))}')
Time dimension: 168 hours
Periods: [2024, 2025, 2026]
Scenarios: ['Mild Winter', 'Harsh Winter']
Scenario weights: {'Mild Winter': np.float64(0.6), 'Harsh Winter': np.float64(0.4)}
Create Scenario-Dependent Demand Profiles¶
Heat demand differs significantly between mild and harsh winters:
In [3]:
Copied!
hours = np.arange(168)
hour_of_day = hours % 24
# Base daily pattern (kW): higher in morning/evening
daily_pattern = np.select(
[
(hour_of_day >= 6) & (hour_of_day < 9), # Morning peak
(hour_of_day >= 9) & (hour_of_day < 17), # Daytime
(hour_of_day >= 17) & (hour_of_day < 22), # Evening peak
],
[180, 120, 160],
default=100, # Night
).astype(float)
# Add random variation
np.random.seed(42)
noise = np.random.normal(0, 10, len(timesteps))
# Mild winter: lower demand
mild_demand = daily_pattern * 0.8 + noise
mild_demand = np.clip(mild_demand, 60, 200)
# Harsh winter: higher demand
harsh_demand = daily_pattern * 1.3 + noise * 1.5
harsh_demand = np.clip(harsh_demand, 100, 280)
# Create DataFrame with scenario columns (flixopt uses column names to match scenarios)
heat_demand = pd.DataFrame(
{
'Mild Winter': mild_demand,
'Harsh Winter': harsh_demand,
},
index=timesteps,
)
print(f'Mild winter demand: {mild_demand.min():.0f} - {mild_demand.max():.0f} kW')
print(f'Harsh winter demand: {harsh_demand.min():.0f} - {harsh_demand.max():.0f} kW')
hours = np.arange(168) hour_of_day = hours % 24 # Base daily pattern (kW): higher in morning/evening daily_pattern = np.select( [ (hour_of_day >= 6) & (hour_of_day < 9), # Morning peak (hour_of_day >= 9) & (hour_of_day < 17), # Daytime (hour_of_day >= 17) & (hour_of_day < 22), # Evening peak ], [180, 120, 160], default=100, # Night ).astype(float) # Add random variation np.random.seed(42) noise = np.random.normal(0, 10, len(timesteps)) # Mild winter: lower demand mild_demand = daily_pattern * 0.8 + noise mild_demand = np.clip(mild_demand, 60, 200) # Harsh winter: higher demand harsh_demand = daily_pattern * 1.3 + noise * 1.5 harsh_demand = np.clip(harsh_demand, 100, 280) # Create DataFrame with scenario columns (flixopt uses column names to match scenarios) heat_demand = pd.DataFrame( { 'Mild Winter': mild_demand, 'Harsh Winter': harsh_demand, }, index=timesteps, ) print(f'Mild winter demand: {mild_demand.min():.0f} - {mild_demand.max():.0f} kW') print(f'Harsh winter demand: {harsh_demand.min():.0f} - {harsh_demand.max():.0f} kW')
Mild winter demand: 60 - 163 kW Harsh winter demand: 100 - 262 kW
In [4]:
Copied!
# Visualize demand scenarios with plotly
fig = px.line(
heat_demand.iloc[:48],
title='Heat Demand by Scenario (First 2 Days)',
labels={'index': 'Time', 'value': 'kW', 'variable': 'Scenario'},
)
fig.update_traces(mode='lines')
fig
# Visualize demand scenarios with plotly fig = px.line( heat_demand.iloc[:48], title='Heat Demand by Scenario (First 2 Days)', labels={'index': 'Time', 'value': 'kW', 'variable': 'Scenario'}, ) fig.update_traces(mode='lines') fig
Create Period-Dependent Prices¶
Energy prices change across planning years:
In [5]:
Copied!
# Gas prices by period (€/kWh) - expected to rise
gas_prices = np.array([0.06, 0.08, 0.10]) # 2024, 2025, 2026
# Electricity sell prices by period (€/kWh) - CHP revenue
elec_prices = np.array([0.28, 0.34, 0.43]) # Rising with gas
print('Gas prices by period:')
for period, price in zip(periods, gas_prices, strict=False):
print(f' {period}: {price:.2f} €/kWh')
print('\nElectricity sell prices by period:')
for period, price in zip(periods, elec_prices, strict=False):
print(f' {period}: {price:.2f} €/kWh')
# Gas prices by period (€/kWh) - expected to rise gas_prices = np.array([0.06, 0.08, 0.10]) # 2024, 2025, 2026 # Electricity sell prices by period (€/kWh) - CHP revenue elec_prices = np.array([0.28, 0.34, 0.43]) # Rising with gas print('Gas prices by period:') for period, price in zip(periods, gas_prices, strict=False): print(f' {period}: {price:.2f} €/kWh') print('\nElectricity sell prices by period:') for period, price in zip(periods, elec_prices, strict=False): print(f' {period}: {price:.2f} €/kWh')
Gas prices by period: 2024: 0.06 €/kWh 2025: 0.08 €/kWh 2026: 0.10 €/kWh Electricity sell prices by period: 2024: 0.28 €/kWh 2025: 0.34 €/kWh 2026: 0.43 €/kWh
Build the Flow System¶
Initialize with all dimensions:
In [6]:
Copied!
flow_system = fx.FlowSystem(
timesteps=timesteps,
periods=periods,
scenarios=scenarios,
scenario_weights=scenario_weights,
)
flow_system.add_carriers(
fx.Carrier('gas', '#3498db', 'kW'),
fx.Carrier('electricity', '#f1c40f', 'kW'),
fx.Carrier('heat', '#e74c3c', 'kW'),
)
print(flow_system)
flow_system = fx.FlowSystem( timesteps=timesteps, periods=periods, scenarios=scenarios, scenario_weights=scenario_weights, ) flow_system.add_carriers( fx.Carrier('gas', '#3498db', 'kW'), fx.Carrier('electricity', '#f1c40f', 'kW'), fx.Carrier('heat', '#e74c3c', 'kW'), ) print(flow_system)
FlowSystem ========== Timesteps: 168 (Hour) [2024-01-15 to 2024-01-21] Periods: 3 (2024, 2025, 2026) Scenarios: 2 (Mild Winter, Harsh Winter) Status: ⚠
Add Components¶
In [7]:
Copied!
flow_system.add_elements(
# === Buses ===
fx.Bus('Electricity', carrier='electricity'),
fx.Bus('Heat', carrier='heat'),
fx.Bus('Gas', carrier='gas'),
# === Effects ===
fx.Effect('costs', '€', 'Total Costs', is_standard=True, is_objective=True),
# === Gas Supply (price varies by period) ===
fx.Source(
'GasGrid',
outputs=[
fx.Flow(
'Gas',
bus='Gas',
size=1000,
effects_per_flow_hour=gas_prices, # Array = varies by period
)
],
),
# === CHP Unit (investment decision) ===
fx.linear_converters.CHP(
'CHP',
electrical_efficiency=0.35,
thermal_efficiency=0.50,
electrical_flow=fx.Flow(
'P_el',
bus='Electricity',
# Investment optimization: find optimal CHP size
size=fx.InvestParameters(
minimum_size=0,
maximum_size=100,
effects_of_investment_per_size={'costs': 15}, # 15 €/kW/week annualized
),
),
thermal_flow=fx.Flow('Q_th', bus='Heat'),
fuel_flow=fx.Flow('Q_fuel', bus='Gas'),
),
# === Gas Boiler (existing backup) ===
fx.linear_converters.Boiler(
'Boiler',
thermal_efficiency=0.90,
thermal_flow=fx.Flow('Q_th', bus='Heat', size=500),
fuel_flow=fx.Flow('Q_fuel', bus='Gas'),
),
# === Electricity Sales (revenue varies by period) ===
fx.Sink(
'ElecSales',
inputs=[
fx.Flow(
'P_el',
bus='Electricity',
size=100,
effects_per_flow_hour=-elec_prices, # Negative = revenue
)
],
),
# === Heat Demand (varies by scenario) ===
fx.Sink(
'HeatDemand',
inputs=[
fx.Flow(
'Q_th',
bus='Heat',
size=1,
fixed_relative_profile=heat_demand, # DataFrame with scenario columns
)
],
),
)
flow_system.add_elements( # === Buses === fx.Bus('Electricity', carrier='electricity'), fx.Bus('Heat', carrier='heat'), fx.Bus('Gas', carrier='gas'), # === Effects === fx.Effect('costs', '€', 'Total Costs', is_standard=True, is_objective=True), # === Gas Supply (price varies by period) === fx.Source( 'GasGrid', outputs=[ fx.Flow( 'Gas', bus='Gas', size=1000, effects_per_flow_hour=gas_prices, # Array = varies by period ) ], ), # === CHP Unit (investment decision) === fx.linear_converters.CHP( 'CHP', electrical_efficiency=0.35, thermal_efficiency=0.50, electrical_flow=fx.Flow( 'P_el', bus='Electricity', # Investment optimization: find optimal CHP size size=fx.InvestParameters( minimum_size=0, maximum_size=100, effects_of_investment_per_size={'costs': 15}, # 15 €/kW/week annualized ), ), thermal_flow=fx.Flow('Q_th', bus='Heat'), fuel_flow=fx.Flow('Q_fuel', bus='Gas'), ), # === Gas Boiler (existing backup) === fx.linear_converters.Boiler( 'Boiler', thermal_efficiency=0.90, thermal_flow=fx.Flow('Q_th', bus='Heat', size=500), fuel_flow=fx.Flow('Q_fuel', bus='Gas'), ), # === Electricity Sales (revenue varies by period) === fx.Sink( 'ElecSales', inputs=[ fx.Flow( 'P_el', bus='Electricity', size=100, effects_per_flow_hour=-elec_prices, # Negative = revenue ) ], ), # === Heat Demand (varies by scenario) === fx.Sink( 'HeatDemand', inputs=[ fx.Flow( 'Q_th', bus='Heat', size=1, fixed_relative_profile=heat_demand, # DataFrame with scenario columns ) ], ), )
Run Optimization¶
In [8]:
Copied!
flow_system.optimize(fx.solvers.HighsSolver(mip_gap=0.01));
flow_system.optimize(fx.solvers.HighsSolver(mip_gap=0.01));
In [9]:
Copied!
chp_size = flow_system.statistics.sizes['CHP(P_el)']
total_cost = flow_system.solution['costs']
print(f'Optimal CHP: {float(chp_size.max()):.0f} kW electrical ({float(chp_size.max()) * 0.50 / 0.35:.0f} kW thermal)')
print(f'Expected cost: {float(total_cost.sum()):.0f} €')
chp_size = flow_system.statistics.sizes['CHP(P_el)'] total_cost = flow_system.solution['costs'] print(f'Optimal CHP: {float(chp_size.max()):.0f} kW electrical ({float(chp_size.max()) * 0.50 / 0.35:.0f} kW thermal)') print(f'Expected cost: {float(total_cost.sum()):.0f} €')
Optimal CHP: 100 kW electrical (142 kW thermal) Expected cost: 78 €
Heat Balance by Scenario¶
See how the system operates differently in each scenario:
In [10]:
Copied!
flow_system.statistics.plot.balance('Heat')
flow_system.statistics.plot.balance('Heat')
Out[10]:
CHP Operation Patterns¶
In [11]:
Copied!
flow_system.statistics.plot.heatmap('CHP(Q_th)')
flow_system.statistics.plot.heatmap('CHP(Q_th)')
Out[11]:
Multi-Dimensional Data Access¶
Results include all dimensions (time, period, scenario):
In [12]:
Copied!
# View dimensions
flow_rates = flow_system.statistics.flow_rates
print('Flow rates dimensions:', dict(flow_rates.sizes))
# Plot flow rates
flow_system.statistics.plot.flows()
# View dimensions flow_rates = flow_system.statistics.flow_rates print('Flow rates dimensions:', dict(flow_rates.sizes)) # Plot flow rates flow_system.statistics.plot.flows()
Flow rates dimensions: {'period': 3, 'scenario': 2, 'time': 169}
Out[12]:
In [13]:
Copied!
# CHP operation summary by scenario
chp_heat = flow_rates['CHP(Q_th)']
for scenario in scenarios:
scenario_avg = float(chp_heat.sel(scenario=scenario).mean())
scenario_max = float(chp_heat.sel(scenario=scenario).max())
print(f'{scenario}: avg {scenario_avg:.0f} kW, max {scenario_max:.0f} kW')
# CHP operation summary by scenario chp_heat = flow_rates['CHP(Q_th)'] for scenario in scenarios: scenario_avg = float(chp_heat.sel(scenario=scenario).mean()) scenario_max = float(chp_heat.sel(scenario=scenario).max()) print(f'{scenario}: avg {scenario_avg:.0f} kW, max {scenario_max:.0f} kW')
Mild Winter: avg 102 kW, max 142 kW Harsh Winter: avg 134 kW, max 142 kW
Sensitivity: What if Only Mild Winter?¶
Compare optimal CHP size if we only planned for mild winters:
In [14]:
Copied!
# Select only the mild winter scenario
fs_mild = flow_system.transform.sel(scenario='Mild Winter')
fs_mild.optimize(fx.solvers.HighsSolver(mip_gap=0.01))
chp_size_mild = float(fs_mild.statistics.sizes['CHP(P_el)'].max())
chp_size_both = float(chp_size.max())
print(
f'CHP sizing: {chp_size_mild:.0f} kW (mild only) vs {chp_size_both:.0f} kW (both scenarios) → +{chp_size_both - chp_size_mild:.0f} kW for uncertainty'
)
# Select only the mild winter scenario fs_mild = flow_system.transform.sel(scenario='Mild Winter') fs_mild.optimize(fx.solvers.HighsSolver(mip_gap=0.01)) chp_size_mild = float(fs_mild.statistics.sizes['CHP(P_el)'].max()) chp_size_both = float(chp_size.max()) print( f'CHP sizing: {chp_size_mild:.0f} kW (mild only) vs {chp_size_both:.0f} kW (both scenarios) → +{chp_size_both - chp_size_mild:.0f} kW for uncertainty' )
CHP sizing: 85 kW (mild only) vs 100 kW (both scenarios) → +15 kW for uncertainty
Energy Flow Sankey¶
A Sankey diagram visualizes the total energy flows through the system:
In [15]:
Copied!
flow_system.statistics.plot.sankey.flows()
flow_system.statistics.plot.sankey.flows()
Out[15]:
Key Concepts¶
Multi-Dimensional FlowSystem¶
flow_system = fx.FlowSystem(
timesteps=timesteps, # Time dimension
periods=periods, # Planning periods (years)
scenarios=scenarios, # Uncertain futures
scenario_weights=weights, # Probabilities
)
Dimension-Varying Parameters¶
| Data Shape | Meaning |
|---|---|
| Scalar | Same for all time/period/scenario |
| Array (n_periods,) | Varies by period |
| Array (n_scenarios,) | Varies by scenario |
| DataFrame with columns | Columns match scenario names |
| Full array (time, period, scenario) | Full specification |
Scenario Optimization¶
The optimizer minimizes expected cost: $$\min \sum_s w_s \cdot \text{Cost}_s$$
where $w_s$ is the scenario weight (probability).
Selection Methods¶
# Select specific scenario
fs_mild = flow_system.transform.sel(scenario='Mild Winter')
# Select specific period
fs_2025 = flow_system.transform.sel(period=2025)
# Select time range
fs_day1 = flow_system.transform.sel(time=slice('2024-01-15', '2024-01-16'))
Summary¶
You learned how to:
- Define multiple periods for multi-year planning
- Create scenarios for uncertain futures
- Use scenario weights for probability-weighted optimization
- Pass dimension-varying parameters (arrays and DataFrames)
- Select specific scenarios or periods for analysis
Next Steps¶
- 08a-Aggregation: Speed up large problems with resampling and clustering
- 08b-Rolling Horizon: Decompose large problems into sequential time segments