Rolling Horizon¶
Solve large operational problems by decomposing the time horizon into sequential segments.
This notebook introduces:
- Rolling horizon optimization: Divide time into overlapping segments
- State transfer: Pass storage states and flow history between segments
- When to use: Memory limits, operational planning with limited foresight
We use a realistic district heating system with CHP, boiler, and storage to demonstrate the approach.
Setup¶
import timeit
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import xarray as xr
from plotly.subplots import make_subplots
import flixopt as fx
fx.CONFIG.notebook()
flixopt.config.CONFIG
Create the FlowSystem¶
We use an operational district heating system with real-world data (two weeks at 15-min resolution):
from data.generate_example_systems import create_operational_system
flow_system = create_operational_system().transform.resample('1h')
flow_system.connect_and_transform() # Align all data as xarray
timesteps = flow_system.timesteps
print(f'Loaded FlowSystem: {len(timesteps)} timesteps ({len(timesteps) / 24:.0f} days at 1h resolution)')
print(f'Components: {list(flow_system.components.keys())}')
Loaded FlowSystem: 336 timesteps (14 days at 1h resolution) Components: ['CHP', 'Boiler', 'Storage', 'GasGrid', 'CoalSupply', 'GridBuy', 'GridSell', 'HeatDemand', 'ElecDemand']
Full Optimization (Baseline)¶
First, solve the full problem as a baseline:
solver = fx.solvers.HighsSolver(mip_gap=0.01, time_limit_seconds=240)
fs_full = flow_system.copy()
fs_full.name = 'Full Optimization'
start = timeit.default_timer()
fs_full.optimize(solver)
time_full = timeit.default_timer() - start
print(f'Full: {time_full:.1f}s, {fs_full.solution["costs"].item():.0f} €')
Full: 148.2s, -3829 €
Rolling Horizon Optimization¶
The optimize.rolling_horizon() method divides the time horizon into segments that are solved sequentially:
Full horizon: |---------- 336 timesteps (14 days) ----------|
Segment 1: |==== 96 (4 days) ====|-- overlap --|
Segment 2: |==== 96 (4 days) ====|-- overlap --|
Segment 3: |==== 96 (4 days) ====|-- overlap --|
...
Key parameters:
- horizon: Timesteps per segment (excluding overlap)
- overlap: Additional lookahead timesteps (improves storage optimization)
- nr_of_previous_values: Flow history transferred between segments
start = timeit.default_timer()
fs_rolling = flow_system.copy()
fs_rolling.name = 'Rolling Horizon'
segments = fs_rolling.optimize.rolling_horizon(
solver,
horizon=96, # 4-day segments (96 timesteps at 1h resolution)
overlap=24, # 1-day lookahead
)
time_rolling = timeit.default_timer() - start
print(f'Rolling ({len(segments)} segments): {time_rolling:.1f}s, {fs_rolling.solution["costs"].item():.0f} €')
HighsMipSolverData::transformNewIntegerFeasibleSolution tmpSolver.run();
Rolling (4 segments): 98.1s, 20505 €
Compare Results¶
cost_full = fs_full.solution['costs'].item()
cost_rolling = fs_rolling.solution['costs'].item()
cost_gap = (cost_rolling - cost_full) / abs(cost_full) * 100 if cost_full != 0 else 0.0
results = pd.DataFrame(
{
'Method': ['Full optimization', 'Rolling horizon'],
'Time [s]': [time_full, time_rolling],
'Cost [€]': [cost_full, cost_rolling],
'Cost Gap [%]': [0.0, cost_gap],
}
).set_index('Method')
results.style.format({'Time [s]': '{:.2f}', 'Cost [€]': '{:.0f}', 'Cost Gap [%]': '{:.2f}'})
| Time [s] | Cost [€] | Cost Gap [%] | |
|---|---|---|---|
| Method | |||
| Full optimization | 148.23 | -3829 | 0.00 |
| Rolling horizon | 98.11 | 20505 | 635.53 |
Visualize: Heat Balance Comparison¶
Use the Comparison class to view both methods side-by-side:
comp = fx.Comparison([fs_full, fs_rolling])
comp.stats.plot.effects(by='contributor', effect='costs')
Storage State Continuity¶
Rolling horizon transfers storage charge states between segments to ensure continuity:
fig = make_subplots(
rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1, subplot_titles=['Full Optimization', 'Rolling Horizon']
)
# Full optimization
charge_full = fs_full.solution['Storage|charge_state'].values[:-1] # Drop final value
fig.add_trace(go.Scatter(x=timesteps, y=charge_full, name='Full', line=dict(color='blue')), row=1, col=1)
# Rolling horizon
charge_rolling = fs_rolling.solution['Storage|charge_state'].values[:-1]
fig.add_trace(go.Scatter(x=timesteps, y=charge_rolling, name='Rolling', line=dict(color='orange')), row=2, col=1)
fig.update_yaxes(title_text='Charge State [MWh]', row=1, col=1)
fig.update_yaxes(title_text='Charge State [MWh]', row=2, col=1)
fig.update_layout(height=400, showlegend=False)
fig.show()
Inspect Individual Segments¶
The method returns the individual segment FlowSystems, which can be inspected:
print(f'{len(segments)} segments:')
for i, seg in enumerate(segments):
print(
f' {i + 1}: {seg.timesteps[0]:%m-%d %H:%M} → {seg.timesteps[-1]:%m-%d %H:%M} | {seg.solution["costs"].item():,.0f} €'
)
4 segments: 1: 01-01 00:00 → 01-05 23:00 | 18,073 € 2: 01-05 00:00 → 01-09 23:00 | -9,916 € 3: 01-09 00:00 → 01-13 23:00 | 15,378 € 4: 01-13 00:00 → 01-14 23:00 | -5,875 €
Visualize Segment Overlaps¶
Understanding how segments overlap is key to tuning rolling horizon. Let's visualize the flow rates from each segment including their overlap regions:
# Concatenate all segment solutions into one dataset (including overlaps)
ds = xr.concat([seg.solution for seg in segments], dim=pd.RangeIndex(len(segments), name='segment'), join='outer')
# Plot CHP thermal flow across all segments - each segment as a separate line
px.line(
ds['Boiler(Q_th)|flow_rate'].to_pandas().T,
labels={'value': 'Boiler Thermal Output [MW]', 'index': 'Timestep'},
)
px.line(
ds['Storage|charge_state'].to_pandas().T,
labels={'value': 'Storage Charge State [MW]', 'index': 'Timestep'},
)
When to Use Rolling Horizon¶
| Use Case | Recommendation |
|---|---|
| Memory limits | Large problems that exceed available memory |
| Operational planning | When limited foresight is realistic |
| Quick approximate solutions | Faster than full optimization |
| Investment decisions | Use full optimization instead |
Limitations¶
- No investments:
InvestParametersare not supported (raises error) - Suboptimal storage: Limited foresight may miss long-term storage opportunities
- Global constraints:
flow_hours_maxetc. cannot be enforced globally
API Reference¶
segments = flow_system.optimize.rolling_horizon(
solver, # Solver instance
horizon=192, # Timesteps per segment (e.g., 2 days at 15-min resolution)
overlap=48, # Additional lookahead timesteps (e.g., 12 hours)
nr_of_previous_values=1, # Flow history for uptime/downtime tracking
)
# Combined solution on original FlowSystem
flow_system.solution['costs'].item()
# Individual segment solutions
for seg in segments:
print(seg.solution['costs'].item())
Summary¶
You learned how to:
- Use
optimize.rolling_horizon()to decompose large problems - Choose horizon and overlap parameters
- Understand the trade-offs vs. full optimization
Key Takeaways¶
- Rolling horizon is useful for memory-limited or operational planning problems
- Overlap improves solution quality at the cost of computation time
- Storage states are automatically transferred between segments
- Use full optimization for investment decisions
Related Notebooks¶
- 08a-Aggregation: For investment problems, use time series aggregation (resampling, clustering) instead