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(id='gas'), Carrier(id='heat')],
effects=[Effect(id='cost', unit='EUR')],
ports=[
Port(
id='gas_grid',
imports=[
Flow(carrier='gas', size=500, effects_per_flow_hour={'cost': price}),
],
),
Port(
id='office',
exports=[
Flow(carrier='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(carrier='gas', size=300),
thermal_flow=Flow(carrier='heat', size=150),
),
],
storages=[
Storage(
id='tank',
capacity=500,
charging=Flow(carrier='heat', size=100),
discharging=Flow(carrier='heat', size=100),
eta_charge=0.98,
eta_discharge=0.98,
relative_loss_per_hour=0.005,
cyclic=True,
)
],
objective='cost',
)
max_d = max(demand)
result = optimize(
timesteps=timesteps,
carriers=[Carrier(id='gas'), Carrier(id='heat')],
effects=[Effect(id='cost', unit='EUR')],
ports=[
Port(
id='gas_grid',
imports=[
Flow(carrier='gas', size=500, effects_per_flow_hour={'cost': price}),
],
),
Port(
id='office',
exports=[
Flow(carrier='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(carrier='gas', size=300),
thermal_flow=Flow(carrier='heat', size=150),
),
],
storages=[
Storage(
id='tank',
capacity=500,
charging=Flow(carrier='heat', size=100),
discharging=Flow(carrier='heat', size=100),
eta_charge=0.98,
eta_discharge=0.98,
relative_loss_per_hour=0.005,
cyclic=True,
)
],
objective='cost',
)
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