Heat System with Storage¶
Thermal storage shifts gas consumption from expensive peak to cheap off-peak hours.
New concepts: Storage (buffer with efficiency & self-discharge) · Time-varying prices · Load shifting
In [1]:
Copied!
import math
from datetime import datetime, timedelta
import plotly.graph_objects as go
import plotly.io as pio
from plotly.subplots import make_subplots
from fluxopt import Carrier, Converter, Effect, Flow, Port, Storage, optimize
pio.renderers.default = 'notebook_connected'
import math
from datetime import datetime, timedelta
import plotly.graph_objects as go
import plotly.io as pio
from plotly.subplots import make_subplots
from fluxopt import Carrier, Converter, Effect, Flow, Port, Storage, optimize
pio.renderers.default = 'notebook_connected'
Input Data — 48 h with day/night price spread¶
In [2]:
Copied!
n = 48
timesteps = [datetime(2024, 1, 15) + timedelta(hours=h) for h in range(n)]
demand = [50 + 40 * max(0.0, math.sin(math.pi * ((h % 24) - 6) / 12)) for h in range(n)]
price = [0.04 if (h % 24) < 6 or (h % 24) >= 22 else 0.08 for h in range(n)]
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
vertical_spacing=0.15,
subplot_titles=('Heat Demand', 'Gas Price'),
)
fig.add_trace(go.Bar(x=timesteps, y=demand, marker_color='#ef553b', showlegend=False), row=1, col=1)
fig.add_trace(go.Scatter(x=timesteps, y=price, line_shape='hv', line_color='#636efa', showlegend=False), row=2, col=1)
fig.update_layout(height=350, margin={'l': 50, 'r': 20, 't': 30, 'b': 20}, template='plotly_white')
fig.update_yaxes(title_text='kW', row=1, col=1)
fig.update_yaxes(title_text='EUR/kWh', row=2, col=1)
fig
n = 48
timesteps = [datetime(2024, 1, 15) + timedelta(hours=h) for h in range(n)]
demand = [50 + 40 * max(0.0, math.sin(math.pi * ((h % 24) - 6) / 12)) for h in range(n)]
price = [0.04 if (h % 24) < 6 or (h % 24) >= 22 else 0.08 for h in range(n)]
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
vertical_spacing=0.15,
subplot_titles=('Heat Demand', 'Gas Price'),
)
fig.add_trace(go.Bar(x=timesteps, y=demand, marker_color='#ef553b', showlegend=False), row=1, col=1)
fig.add_trace(go.Scatter(x=timesteps, y=price, line_shape='hv', line_color='#636efa', showlegend=False), row=2, col=1)
fig.update_layout(height=350, margin={'l': 50, 'r': 20, 't': 30, 'b': 20}, template='plotly_white')
fig.update_yaxes(title_text='kW', row=1, col=1)
fig.update_yaxes(title_text='EUR/kWh', row=2, col=1)
fig
System¶
Gas Grid ──► [gas] ──► Boiler η=92% ──► [heat] ◄──► Tank 500 kWh
time-varying € │
Office
In [3]:
Copied!
max_d = max(demand)
result = optimize(
timesteps=timesteps,
carriers=[Carrier('gas'), Carrier('heat')],
effects=[Effect('cost', unit='EUR', is_objective=True)],
ports=[
Port(
'gas_grid',
imports=[
Flow('gas', size=500, effects_per_flow_hour={'cost': price}),
],
),
Port(
'office',
exports=[
Flow('heat', size=max_d, fixed_relative_profile=[d / max_d for d in demand]),
],
),
],
converters=[
Converter.boiler(
'boiler',
thermal_efficiency=0.92,
fuel_flow=Flow('gas', size=300),
thermal_flow=Flow('heat', size=150),
),
],
storages=[
Storage(
'tank',
capacity=500,
charging=Flow('heat', size=100),
discharging=Flow('heat', size=100),
eta_charge=0.98,
eta_discharge=0.98,
relative_loss_per_hour=0.005,
cyclic=True,
)
],
)
max_d = max(demand)
result = optimize(
timesteps=timesteps,
carriers=[Carrier('gas'), Carrier('heat')],
effects=[Effect('cost', unit='EUR', is_objective=True)],
ports=[
Port(
'gas_grid',
imports=[
Flow('gas', size=500, effects_per_flow_hour={'cost': price}),
],
),
Port(
'office',
exports=[
Flow('heat', size=max_d, fixed_relative_profile=[d / max_d for d in demand]),
],
),
],
converters=[
Converter.boiler(
'boiler',
thermal_efficiency=0.92,
fuel_flow=Flow('gas', size=300),
thermal_flow=Flow('heat', size=150),
),
],
storages=[
Storage(
'tank',
capacity=500,
charging=Flow('heat', size=100),
discharging=Flow('heat', size=100),
eta_charge=0.98,
eta_discharge=0.98,
relative_loss_per_hour=0.005,
cyclic=True,
)
],
)
Running HiGHS 1.13.1 (git hash: 1d267d9): Copyright (c) 2026 under MIT licence terms
LP linopy-problem-qifsnofb has 871 rows; 439 cols; 1404 nonzeros
Coefficient ranges:
Matrix [4e-02, 1e+00]
Cost [1e+00, 1e+00]
Bound [0e+00, 0e+00]
RHS [5e+01, 5e+02]
Presolving model
96 rows, 192 cols, 336 nonzeros 0s
Dependent equations search running on 96 equations with time limit of 1000.00s
Dependent equations search removed 0 rows and 0 nonzeros in 0.00s (limit = 1000.00s)
96 rows, 192 cols, 336 nonzeros 0s
Presolve reductions: rows 96(-775); columns 192(-247); nonzeros 336(-1068)
Solving the presolved LP
Using dual simplex solver
Iteration Objective Infeasibilities num(sum)
0 0.0000000000e+00 Pr: 48(3007.66) 0.0s
133 1.8811941584e+02 Pr: 0(0) 0.0s
Performed postsolve
Solving the original LP from the solution after postsolve
Model name : linopy-problem-qifsnofb
Model status : Optimal
Simplex iterations: 133
Objective value : 1.8811941584e+02
P-D objective error : 0.0000000000e+00
HiGHS run time : 0.00
/home/runner/work/fluxopt/fluxopt/.venv/lib/python3.12/site-packages/linopy/common.py:491: UserWarning: Coordinates across variables not equal. Perform outer join. warn(
Results¶
In [4]:
Copied!
print(f'Total cost: {result.objective:.2f} EUR | Avg: {result.objective / sum(demand) * 100:.2f} ct/kWh')
print(f'Total cost: {result.objective:.2f} EUR | Avg: {result.objective / sum(demand) * 100:.2f} ct/kWh')
Total cost: 188.12 EUR | Avg: 6.25 ct/kWh
In [5]:
Copied!
times = result.flow_rates.coords['time'].values
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
vertical_spacing=0.12,
subplot_titles=('Storage Flows', 'Storage Level'),
)
for fid in result.flow_rates.coords['flow'].values:
if str(fid).startswith('tank('):
fig.add_trace(
go.Scatter(x=times, y=result.flow_rate(str(fid)).values, name=str(fid), line_shape='hv'),
row=1,
col=1,
)
level = result.storage_level('tank')
fig.add_trace(
go.Scatter(
x=level.coords['time'].values,
y=level.values,
name='level',
fill='tozeroy',
line_shape='hv',
showlegend=False,
),
row=2,
col=1,
)
fig.update_layout(height=400, margin={'l': 50, 'r': 20, 't': 30, 'b': 20}, template='plotly_white')
fig.update_yaxes(title_text='kW', row=1, col=1)
fig.update_yaxes(title_text='kWh', row=2, col=1)
fig
times = result.flow_rates.coords['time'].values
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
vertical_spacing=0.12,
subplot_titles=('Storage Flows', 'Storage Level'),
)
for fid in result.flow_rates.coords['flow'].values:
if str(fid).startswith('tank('):
fig.add_trace(
go.Scatter(x=times, y=result.flow_rate(str(fid)).values, name=str(fid), line_shape='hv'),
row=1,
col=1,
)
level = result.storage_level('tank')
fig.add_trace(
go.Scatter(
x=level.coords['time'].values,
y=level.values,
name='level',
fill='tozeroy',
line_shape='hv',
showlegend=False,
),
row=2,
col=1,
)
fig.update_layout(height=400, margin={'l': 50, 'r': 20, 't': 30, 'b': 20}, template='plotly_white')
fig.update_yaxes(title_text='kW', row=1, col=1)
fig.update_yaxes(title_text='kWh', row=2, col=1)
fig
In [6]:
Copied!
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
vertical_spacing=0.12,
subplot_titles=('All Flow Rates', 'Hourly Cost'),
)
for fid in result.flow_rates.coords['flow'].values:
fig.add_trace(
go.Scatter(x=times, y=result.flow_rate(str(fid)).values, name=str(fid), line_shape='hv'),
row=1,
col=1,
)
cost = result.effects_temporal.sel(effect='cost')
fig.add_trace(go.Bar(x=times, y=cost.values, name='cost', showlegend=False), row=2, col=1)
fig.update_layout(height=400, margin={'l': 50, 'r': 20, 't': 30, 'b': 20}, template='plotly_white')
fig.update_yaxes(title_text='kW', row=1, col=1)
fig.update_yaxes(title_text='EUR', row=2, col=1)
fig
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
vertical_spacing=0.12,
subplot_titles=('All Flow Rates', 'Hourly Cost'),
)
for fid in result.flow_rates.coords['flow'].values:
fig.add_trace(
go.Scatter(x=times, y=result.flow_rate(str(fid)).values, name=str(fid), line_shape='hv'),
row=1,
col=1,
)
cost = result.effects_temporal.sel(effect='cost')
fig.add_trace(go.Bar(x=times, y=cost.values, name='cost', showlegend=False), row=2, col=1)
fig.update_layout(height=400, margin={'l': 50, 'r': 20, 't': 30, 'b': 20}, template='plotly_white')
fig.update_yaxes(title_text='kW', row=1, col=1)
fig.update_yaxes(title_text='EUR', row=2, col=1)
fig
Insights¶
- Off-peak charging: boiler overproduces at night (cheap gas) to fill storage
- Peak discharge: storage supplements boiler during expensive hours
- Efficiency vs spread: round-trip loss (96%) + self-discharge means shifting only pays when the price gap exceeds the loss cost