Storage¶
T1 left the workshop running on a flat-priced gas boiler. Now the gas grid offers time-of-use prices — cheap at night, expensive during the day — and we add a thermal tank that stores heat. The optimizer should run the boiler when gas is cheap, store the heat, and discharge it when gas is expensive.
You'll meet Storage — a buffer with capacity, charge/discharge efficiency, and self-discharge.
from datetime import datetime, timedelta
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.io as pio
from fluxopt import Carrier, Converter, Effect, Flow, Port, Storage, optimize
pio.renderers.default = 'notebook_connected'
1. The setup¶
A 24-hour horizon with cheap nights (0.04 €/kWh, 22:00–06:00) and expensive days (0.12 €/kWh). Heat demand peaks mid-day. Both profiles are plotted below.
n = 24
timesteps = [datetime(2024, 1, 15) + timedelta(hours=h) for h in range(n)]
gas_price = [0.04 if h < 6 or h >= 22 else 0.12 for h in range(n)]
demand = np.array([10, 10, 8, 8, 8, 12, 25, 60, 70, 75, 75, 70, 65, 60, 55, 50, 45, 40, 30, 25, 20, 15, 12, 10])
df = pd.DataFrame({'time': timesteps, 'demand (kW)': demand, 'gas price (€/kWh)': gas_price}).melt('time')
fig = px.line(df, x='time', y='value', facet_row='variable', line_shape='hv', height=340)
fig.update_yaxes(matches=None, title='')
fig.for_each_annotation(lambda a: a.update(text=a.text.split('=')[-1]))
fig.update_layout(template='plotly_white', margin={'l': 50, 'r': 20, 't': 30, 'b': 20}, showlegend=False)
fig
2. Add a Storage¶
A Storage has two flows on the same carrier:
charging— energy in (heat from the boiler)discharging— energy out (heat to the workshop)
Plus three properties that make a storage realistic:
capacity— how much can be stored (kWh)eta_charge/eta_discharge— round-trip efficiencyrelative_loss_per_hour— self-discharge (e.g. tank cooldown)
Both flows are on the same carrier (heat), so they'd collide on the default short_id. fluxopt renames them locally to charge / discharge, giving the qualified ids tank(charge) and tank(discharge) you'll see in the plot.
peak = max(demand)
result = optimize(
timesteps=timesteps,
carriers=[Carrier('gas'), Carrier('heat')],
effects=[Effect('cost', unit='EUR')],
ports=[
Port(
'gas_grid',
imports=[
Flow('gas', size=1000, effects_per_flow_hour={'cost': gas_price}),
],
),
Port(
'workshop',
exports=[
Flow('heat', size=peak, fixed_relative_profile=[d / peak for d in demand]),
],
),
],
converters=[
Converter.boiler(
'boiler',
thermal_efficiency=0.9,
fuel_flow=Flow('gas', size=200),
thermal_flow=Flow('heat', size=120),
),
],
storages=[
Storage(
'tank',
capacity=300,
charging=Flow('heat', size=80),
discharging=Flow('heat', size=80),
eta_charge=0.98,
eta_discharge=0.98,
relative_loss_per_hour=0.005,
),
],
objective_effects='cost',
)
print(f'Total cost: {result.objective:.2f} EUR')
Running HiGHS 1.13.1 (git hash: 1d267d9): Copyright (c) 2026 under MIT licence terms
LP linopy-problem-169xn_yp has 437 rows; 221 cols; 704 nonzeros
Coefficient ranges:
Matrix [4e-02, 1e+00]
Cost [1e+00, 1e+00]
Bound [0e+00, 0e+00]
RHS [8e+00, 1e+03]
Presolving model
48 rows, 96 cols, 168 nonzeros 0s
Dependent equations search running on 48 equations with time limit of 1000.00s
Dependent equations search removed 0 rows and 0 nonzeros in 0.00s (limit = 1000.00s)
48 rows, 96 cols, 168 nonzeros 0s
Presolve reductions: rows 48(-389); columns 96(-125); nonzeros 168(-536)
Solving the presolved LP
Using dual simplex solver
Iteration Objective Infeasibilities num(sum)
0 0.0000000000e+00 Pr: 24(858) 0.0s
68 8.2612265705e+01 Pr: 0(0) 0.0s
Performed postsolve
Solving the original LP from the solution after postsolve
Model name : linopy-problem-169xn_yp
Model status : Optimal
Simplex iterations: 68
Objective value : 8.2612265705e+01
P-D objective error : 8.5491922249e-17
HiGHS run time : 0.00
Total cost: 82.61 EUR
/home/docs/checkouts/readthedocs.org/user_builds/fluxopt/envs/v0.0.8/lib/python3.13/site-packages/linopy/common.py:492: UserWarning: Coordinates across variables not equal. Perform outer join. warn(
3. Read the result¶
Two new things to look at:
- Storage level —
result.storage_level('tank')returns the energy stored at each timestep (1-D,time, in kWh). - Storage flows — they appear in
result.flow_rateslike any other flow, with idstank(charge)andtank(discharge).
flows = result.flow_rates.to_dataframe('value').reset_index().assign(panel='flow rate (kW)')
level = result.storage_level('tank').to_dataframe('value').reset_index().assign(panel='tank level (kWh)', flow='tank')
df = pd.concat([flows, level], ignore_index=True)
fig = px.line(df, x='time', y='value', color='flow', facet_row='panel', line_shape='hv', height=440)
fig.update_yaxes(matches=None, title='')
fig.for_each_annotation(lambda a: a.update(text=a.text.split('=')[-1]))
fig.update_layout(template='plotly_white', margin={'l': 50, 'r': 20, 't': 30, 'b': 20})
fig
You should see the boiler running through the cheap night hours filling the tank (tank(charge) rising, level climbing), then the tank discharging through the expensive midday peak (tank(discharge) flowing into the workshop).
The shift only pays when the price gap exceeds the round-trip loss: round-trip efficiency 0.98 × 0.98 ≈ 0.96, plus 0.5 %/h self-discharge, so each stored kWh has to "earn back" roughly 4–10 % of its cost depending on hold time. Try shrinking the price spread or capacity and watch the tank fall idle.
Recap¶
You added one new object — Storage — to the T1 model. The optimizer figured out the charge/discharge schedule on its own; you only declared the physical reality (capacity, efficiency, losses) and the cost signal (TOU gas price).
Next: Status — give the boiler an on/off switch with min run time and startup cost.