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
Load Time Series Data¶
We use real-world district heating data at 15-minute resolution (two weeks):
# Load time series data (15-min resolution)
data = pd.read_csv('data/Zeitreihen2020.csv', index_col=0, parse_dates=True).sort_index()
data = data['2020-01-01':'2020-01-14 23:45:00'] # Two weeks
data.index.name = 'time' # Rename index for consistency
timesteps = data.index
# Extract profiles
electricity_demand = data['P_Netz/MW'].to_numpy()
heat_demand = data['Q_Netz/MW'].to_numpy()
electricity_price = data['Strompr.€/MWh'].to_numpy()
gas_price = data['Gaspr.€/MWh'].to_numpy()
print(
f'{len(timesteps)} timesteps ({len(timesteps) / 96:.0f} days), heat {heat_demand.min():.0f}-{heat_demand.max():.0f} MW'
)
1344 timesteps (14 days), heat 122-254 MW
def build_system(timesteps, heat_demand, electricity_demand, electricity_price, gas_price):
"""Build a district heating system with CHP, boiler, and storage."""
fs = fx.FlowSystem(timesteps)
# Effects
# Buses
fs.add_elements(
fx.Bus('Electricity'),
fx.Bus('Heat'),
fx.Bus('Gas'),
fx.Bus('Coal'),
fx.Effect('costs', '€', 'Total Costs', is_standard=True, is_objective=True),
fx.Effect('CO2', 'kg', 'CO2 Emissions'),
fx.linear_converters.CHP(
'CHP',
thermal_efficiency=0.58,
electrical_efficiency=0.22,
status_parameters=fx.StatusParameters(effects_per_startup=24000),
electrical_flow=fx.Flow('P_el', bus='Electricity', size=200),
thermal_flow=fx.Flow('Q_th', bus='Heat', size=200),
fuel_flow=fx.Flow('Q_fu', bus='Coal', size=288, relative_minimum=87 / 288, previous_flow_rate=100),
),
fx.linear_converters.Boiler(
'Boiler',
thermal_efficiency=0.85,
thermal_flow=fx.Flow('Q_th', bus='Heat'),
fuel_flow=fx.Flow(
'Q_fu',
bus='Gas',
size=95,
relative_minimum=12 / 95,
previous_flow_rate=20,
status_parameters=fx.StatusParameters(effects_per_startup=1000),
),
),
fx.Storage(
'Storage',
capacity_in_flow_hours=684,
initial_charge_state=137,
minimal_final_charge_state=137,
maximal_final_charge_state=158,
eta_charge=1,
eta_discharge=1,
relative_loss_per_hour=0.001,
prevent_simultaneous_charge_and_discharge=True,
charging=fx.Flow('Charge', size=137, bus='Heat'),
discharging=fx.Flow('Discharge', size=158, bus='Heat'),
),
fx.Source(
'GasGrid',
outputs=[fx.Flow('Q_Gas', bus='Gas', size=1000, effects_per_flow_hour={'costs': gas_price, 'CO2': 0.3})],
),
fx.Source(
'CoalSupply',
outputs=[fx.Flow('Q_Coal', bus='Coal', size=1000, effects_per_flow_hour={'costs': 4.6, 'CO2': 0.3})],
),
fx.Source(
'GridBuy',
outputs=[
fx.Flow(
'P_el',
bus='Electricity',
size=1000,
effects_per_flow_hour={'costs': electricity_price + 0.5, 'CO2': 0.3},
)
],
),
fx.Sink(
'GridSell',
inputs=[fx.Flow('P_el', bus='Electricity', size=1000, effects_per_flow_hour=-(electricity_price - 0.5))],
),
fx.Sink('HeatDemand', inputs=[fx.Flow('Q_th', bus='Heat', size=1, fixed_relative_profile=heat_demand)]),
fx.Sink(
'ElecDemand', inputs=[fx.Flow('P_el', bus='Electricity', size=1, fixed_relative_profile=electricity_demand)]
),
)
return fs
flow_system = build_system(timesteps, heat_demand, electricity_demand, electricity_price, gas_price)
print(f'System: {len(timesteps)} timesteps')
System: 1344 timesteps
Full Optimization (Baseline)¶
First, solve the full problem as a baseline:
solver = fx.solvers.HighsSolver()
start = timeit.default_timer()
fs_full = flow_system.copy()
fs_full.optimize(solver)
time_full = timeit.default_timer() - start
print(f'Full: {time_full:.1f}s, {fs_full.solution["costs"].item():,.0f} €')
Full: 16.8s, 1,546,847 €
Rolling Horizon Optimization¶
The optimize.rolling_horizon() method divides the time horizon into segments that are solved sequentially:
Full horizon: |---------- 1344 timesteps (14 days) ----------|
Segment 1: |==== 192 (2 days) ====|-- overlap --|
Segment 2: |==== 192 (2 days) ====|-- overlap --|
Segment 3: |==== 192 (2 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()
segments = fs_rolling.optimize.rolling_horizon(
solver,
horizon=192, # 2-day segments (192 timesteps at 15-min resolution)
overlap=96, # 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} €')
Rolling (7 segments): 29.3s, 1,540,610 €
Compare Results¶
cost_full = fs_full.solution['costs'].item()
cost_rolling = fs_rolling.solution['costs'].item()
cost_gap = (cost_rolling - cost_full) / cost_full * 100
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 | 16.83 | 1,546,847 | 0.00 |
| Rolling horizon | 29.35 | 1,540,610 | -0.40 |
Visualize: Heat Balance Comparison¶
fs_full.statistics.plot.balance('Heat').figure.update_layout(title='Heat Balance (Full)')
fs_rolling.statistics.plot.balance('Heat').figure.update_layout(title='Heat Balance (Rolling)')
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} €'
)
7 segments: 1: 01-01 00:00 → 01-03 23:45 | 318,658 € 2: 01-03 00:00 → 01-05 23:45 | 275,383 € 3: 01-05 00:00 → 01-07 23:45 | 334,799 € 4: 01-07 00:00 → 01-09 23:45 | 406,776 € 5: 01-09 00:00 → 01-11 23:45 | 356,748 € 6: 01-11 00:00 → 01-13 23:45 | 275,375 € 7: 01-13 00:00 → 01-14 23:45 | 269,902 €
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