Clustering Storage Modes¶
Compare different storage handling modes when clustering time series.
This notebook demonstrates:
- Four storage modes:
independent,cyclic,intercluster,intercluster_cyclic - Seasonal storage: Why inter-cluster linking matters for long-term storage
- When to use each mode: Choosing the right mode for your application
!!! note "Requirements" This notebook requires the tsam package with ExtremeConfig support. Install with: pip install "flixopt[full]"
!!! note "Prerequisites" Read 08c-clustering first for clustering basics.
import timeit
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import flixopt as fx
fx.CONFIG.notebook()
flixopt.config.CONFIG
Create the Seasonal Storage System¶
We use a solar thermal + seasonal pit storage system with a full year of data. This is ideal for demonstrating storage modes because:
- Solar peaks in summer when heat demand is low
- Heat demand peaks in winter when solar is minimal
- Seasonal storage bridges this gap by storing summer heat for winter
from data.generate_example_systems import create_seasonal_storage_system
flow_system = create_seasonal_storage_system()
flow_system.connect_and_transform() # Align all data as xarray
timesteps = flow_system.timesteps
print(f'FlowSystem: {len(timesteps)} timesteps ({len(timesteps) / 24:.0f} days)')
print(f'Components: {list(flow_system.components.keys())}')
FlowSystem: 8760 timesteps (365 days) Components: ['SolarThermal', 'GasBoiler', 'GasGrid', 'SeasonalStorage', 'HeatDemand']
# Visualize the seasonal patterns
solar_profile = flow_system.components['SolarThermal'].outputs[0].fixed_relative_profile
heat_demand = flow_system.components['HeatDemand'].inputs[0].fixed_relative_profile
# Compute daily averages using xarray resample
solar_daily = solar_profile.resample(time='1D').mean()
demand_daily = heat_demand.resample(time='1D').mean()
fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1)
fig.add_trace(
go.Scatter(x=solar_daily.time.values, y=solar_daily.values, name='Solar (daily avg)', fill='tozeroy'), row=1, col=1
)
fig.add_trace(
go.Scatter(x=demand_daily.time.values, y=demand_daily.values, name='Heat Demand (daily avg)', fill='tozeroy'),
row=2,
col=1,
)
fig.update_layout(height=400, title='Seasonal Mismatch: Solar vs Heat Demand')
fig.update_xaxes(title_text='Day of Year', row=2, col=1)
fig.update_yaxes(title_text='Solar Profile', row=1, col=1)
fig.update_yaxes(title_text='Heat Demand [MW]', row=2, col=1)
fig.show()
Understanding Storage Modes¶
When clustering reduces a full year to typical periods (e.g., 12 typical days), we need to decide how storage behaves across these periods. Each Storage component has a cluster_mode parameter with four options:
| Mode | Description | Use Case |
|---|---|---|
'intercluster_cyclic' | Links storage across clusters + yearly cyclic | Default. Seasonal storage, yearly optimization |
'intercluster' | Links storage across clusters, free start/end | Multi-year optimization, flexible boundaries |
'cyclic' | Each cluster independent, but cyclic (start = end) | Daily storage only, no seasonal effects |
'independent' | Each cluster independent, free start/end | Fastest solve, ignores long-term storage |
Let's compare them!
Baseline: Full Year Optimization¶
First, optimize the full system to establish a baseline:
solver = fx.solvers.HighsSolver(mip_gap=0.02)
start = timeit.default_timer()
fs_full = flow_system.copy()
fs_full.name = 'Full Optimization'
fs_full.optimize(solver)
time_full = timeit.default_timer() - start
print(f'Full optimization: {time_full:.1f} seconds')
print(f'Total cost: {fs_full.solution["costs"].item():,.0f} EUR')
print('\nOptimized sizes:')
for name, size in fs_full.stats.sizes.items():
print(f' {name}: {float(size.item()):.2f}')
Full optimization: 312.6 seconds Total cost: 0 EUR Optimized sizes: SolarThermal(Q_th): 0.00 GasBoiler(Q_th): 0.00 SeasonalStorage(Charge): 0.00 SeasonalStorage(Discharge): 0.00 SeasonalStorage: 0.00
Compare Storage Modes¶
Now let's cluster with each storage mode and compare results. We set cluster_mode on the Storage component before calling cluster():
from tsam import ExtremeConfig
# Clustering parameters
N_CLUSTERS = 24 # 24 typical days for a full year
CLUSTER_DURATION = '1D'
PEAK_SERIES = ['HeatDemand(Q_th)|fixed_relative_profile']
# Storage modes to compare
storage_modes = ['independent', 'cyclic', 'intercluster', 'intercluster_cyclic']
results = {}
clustered_systems = {}
for mode in storage_modes:
print(f'\n--- Mode: {mode} ---')
# Create a copy and set the storage mode
fs_copy = flow_system.copy()
fs_copy.storages['SeasonalStorage'].cluster_mode = mode
start = timeit.default_timer()
fs_clustered = fs_copy.transform.cluster(
n_clusters=N_CLUSTERS,
cluster_duration=CLUSTER_DURATION,
extremes=ExtremeConfig(method='new_cluster', max_value=PEAK_SERIES),
)
time_cluster = timeit.default_timer() - start
start = timeit.default_timer()
fs_clustered.optimize(solver)
time_solve = timeit.default_timer() - start
clustered_systems[mode] = fs_clustered
results[mode] = {
'Time [s]': time_cluster + time_solve,
'Cost [EUR]': fs_clustered.solution['costs'].item(),
'Solar [MW]': fs_clustered.stats.sizes.get('SolarThermal(Q_th)', 0),
'Boiler [MW]': fs_clustered.stats.sizes.get('GasBoiler(Q_th)', 0),
'Storage [MWh]': fs_clustered.stats.sizes.get('SeasonalStorage', 0),
}
# Handle xarray types
for key in ['Solar [MW]', 'Boiler [MW]', 'Storage [MWh]']:
val = results[mode][key]
results[mode][key] = float(val.item()) if hasattr(val, 'item') else float(val)
print(f' Time: {results[mode]["Time [s]"]:.1f}s')
print(f' Cost: {results[mode]["Cost [EUR]"]:,.0f} EUR')
print(f' Storage: {results[mode]["Storage [MWh]"]:.0f} MWh')
--- Mode: independent ---
Time: 3.5s Cost: 49,823 EUR Storage: 155 MWh --- Mode: cyclic ---
Time: 6.7s Cost: 1,415,045 EUR Storage: 13 MWh --- Mode: intercluster ---
Time: 3.9s Cost: 600,322 EUR Storage: 12780 MWh --- Mode: intercluster_cyclic ---
Time: 7.4s Cost: 1,216,234 EUR Storage: 5496 MWh
# Add full optimization result for comparison
results['Full (baseline)'] = {
'Time [s]': time_full,
'Cost [EUR]': fs_full.solution['costs'].item(),
'Solar [MW]': float(fs_full.stats.sizes.get('SolarThermal(Q_th)', 0).item()),
'Boiler [MW]': float(fs_full.stats.sizes.get('GasBoiler(Q_th)', 0).item()),
'Storage [MWh]': float(fs_full.stats.sizes.get('SeasonalStorage', 0).item()),
}
# Create comparison DataFrame
comparison = pd.DataFrame(results).T
baseline_cost = comparison.loc['Full (baseline)', 'Cost [EUR]']
baseline_time = comparison.loc['Full (baseline)', 'Time [s]']
comparison['Cost Gap [%]'] = (comparison['Cost [EUR]'] - baseline_cost) / abs(baseline_cost) * 100
comparison['Speedup'] = baseline_time / comparison['Time [s]']
comparison.style.format(
{
'Time [s]': '{:.1f}',
'Cost [EUR]': '{:,.0f}',
'Solar [MW]': '{:.1f}',
'Boiler [MW]': '{:.1f}',
'Storage [MWh]': '{:.0f}',
'Cost Gap [%]': '{:+.1f}',
'Speedup': '{:.1f}x',
}
)
| Time [s] | Cost [EUR] | Solar [MW] | Boiler [MW] | Storage [MWh] | Cost Gap [%] | Speedup | |
|---|---|---|---|---|---|---|---|
| independent | 3.5 | 49,823 | 0.0 | 0.0 | 155 | +inf | 88.6x |
| cyclic | 6.7 | 1,415,045 | 1.2 | 6.4 | 13 | +inf | 46.7x |
| intercluster | 3.9 | 600,322 | 16.5 | 0.0 | 12780 | +inf | 80.1x |
| intercluster_cyclic | 7.4 | 1,216,234 | 16.5 | 4.8 | 5496 | +inf | 42.1x |
| Full (baseline) | 312.6 | 0 | 0.0 | 0.0 | 0 | +nan | 1.0x |
Visualize Storage Behavior¶
The key difference between modes is how storage is utilized across the year. Let's expand each solution back to full resolution and compare:
# Expand clustered solutions to full resolution
expanded_systems = {}
for mode in storage_modes:
fs_expanded = clustered_systems[mode].transform.expand()
fs_expanded.name = f'Mode: {mode}'
expanded_systems[mode] = fs_expanded
# Plot storage charge state for each mode
fig = make_subplots(
rows=len(storage_modes) + 1,
cols=1,
shared_xaxes=True,
vertical_spacing=0.05,
subplot_titles=['Full Optimization'] + [f'Mode: {m}' for m in storage_modes],
)
# Full optimization
soc_full = fs_full.solution['SeasonalStorage|charge_state']
fig.add_trace(go.Scatter(x=fs_full.timesteps, y=soc_full.values, name='Full', line=dict(width=0.8)), row=1, col=1)
# Expanded clustered solutions
for i, mode in enumerate(storage_modes, start=2):
fs_exp = expanded_systems[mode]
soc = fs_exp.solution['SeasonalStorage|charge_state']
fig.add_trace(go.Scatter(x=fs_exp.timesteps, y=soc.values, name=mode, line=dict(width=0.8)), row=i, col=1)
fig.update_layout(height=800, title='Storage Charge State by Mode', showlegend=False)
for i in range(1, len(storage_modes) + 2):
fig.update_yaxes(title_text='SOC [MWh]', row=i, col=1)
fig.show()
Side-by-Side Comparison¶
Use the Comparison class to compare the full optimization with the recommended mode:
# Compare full optimization with the recommended intercluster_cyclic mode
comp = fx.Comparison([fs_full, expanded_systems['intercluster_cyclic']])
comp.stats.plot.balance('Heat')
Interpretation¶
'independent' Mode¶
- Each typical period is solved independently
- Storage starts and ends at arbitrary states within each cluster
- No seasonal storage benefit captured - storage is only used for daily fluctuations
- Fastest to solve but least accurate for seasonal systems
'cyclic' Mode¶
- Each cluster is independent but enforces start = end state
- Better than independent but still no cross-season linking
- Good for systems where storage only balances within-day variations
'intercluster' Mode¶
- Links storage state across the original time series via typical periods
- Captures seasonal storage behavior - summer charging, winter discharging
- Free start and end states (useful for multi-year optimization)
'intercluster_cyclic' Mode (Default)¶
- Inter-cluster linking plus yearly cyclic constraint (end = start)
- Best for yearly investment optimization with seasonal storage
- Ensures the storage cycle is sustainable year after year
When to Use Each Mode¶
| Your System Has... | Recommended Mode |
|---|---|
| Seasonal storage (pit, underground) | 'intercluster_cyclic' |
| Only daily storage (batteries, hot water tanks) | 'cyclic' |
| Multi-year optimization with inter-annual storage | 'intercluster' |
| Quick sizing estimate, storage not critical | 'independent' |
Setting the Mode¶
# Option 1: Set when creating the Storage
storage = fx.Storage(
'SeasonalStorage',
capacity_in_flow_hours=5000,
cluster_mode='intercluster_cyclic', # default
...
)
# Option 2: Modify before clustering
flow_system.components['SeasonalStorage'].cluster_mode = 'cyclic'
fs_clustered = flow_system.transform.cluster(...)
!!! tip "Rule of Thumb" Use 'intercluster_cyclic' (default) unless you have a specific reason not to. It provides the most accurate representation of storage behavior in clustered systems.
Summary¶
You learned how to:
- Use
cluster_modeon Storage components to control behavior in clustering - Understand the difference between modes and their impact on results
- Choose the right mode for your optimization problem
Key Takeaways¶
- Seasonal storage requires inter-cluster linking to capture charging/discharging across seasons
'intercluster_cyclic'is the default and best for yearly investment optimization'independent'and'cyclic'are faster but miss long-term storage value- Expand solutions with
expand()to visualize storage behavior across the year
Next Steps¶
- 08d-clustering-multiperiod: Clustering with multiple periods and scenarios