Multi-Node Heat Network¶
A central plant supplies heat to a remote district through a pipeline with limited capacity and heat loss. When the pipeline saturates, a local backup boiler covers the remaining demand.
New concepts: Multi-node carriers (node) · Transmission between nodes · Pipeline losses
In [1]:
Copied!
import math
from datetime import datetime, timedelta
import plotly.graph_objects as go
import plotly.io as pio
from fluxopt import Carrier, Converter, Effect, Flow, Port, 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 fluxopt import Carrier, Converter, Effect, Flow, Port, optimize
pio.renderers.default = 'notebook_connected'
System¶
Node A (plant) Node B (district)
Gas Grid ─► Boiler η=92% Backup η=85%
0.05 €/kWh │ 0.12 €/kWh │
[heat:A] ─── Pipeline ───► [heat:B] ──► District
60 kW, η=95%
- Node A has a cheap gas boiler at the central plant
- Node B has the residential demand and an expensive oil backup
- The pipeline transmits up to 60 kW from A to B with 5% heat loss
- When demand exceeds pipeline capacity, the backup kicks in
In [2]:
Copied!
n = 24
timesteps = [datetime(2024, 1, 15) + timedelta(hours=h) for h in range(n)]
# Residential demand: morning + evening peaks, 30 kW baseload
demand = [
30 + 50 * max(0.0, math.sin(math.pi * ((h % 24) - 5) / 6)) + 40 * max(0.0, math.sin(math.pi * ((h % 24) - 16) / 6))
for h in range(n)
]
max_d = max(demand)
n = 24
timesteps = [datetime(2024, 1, 15) + timedelta(hours=h) for h in range(n)]
# Residential demand: morning + evening peaks, 30 kW baseload
demand = [
30 + 50 * max(0.0, math.sin(math.pi * ((h % 24) - 5) / 6)) + 40 * max(0.0, math.sin(math.pi * ((h % 24) - 16) / 6))
for h in range(n)
]
max_d = max(demand)
In [3]:
Copied!
max_d = max(demand)
# Pipeline flows — referenced in conversion_factors
pipe_in = Flow('heat', node='A', size=60)
pipe_out = Flow('heat', node='B', size=60)
result = optimize(
timesteps=timesteps,
carriers=[Carrier('gas'), Carrier('oil'), Carrier('heat', nodes=['A', 'B'])],
effects=[Effect('cost', unit='EUR', is_objective=True)],
ports=[
Port('gas_grid', imports=[Flow('gas', size=200, effects_per_flow_hour={'cost': 0.05})]),
Port('oil_supply', imports=[Flow('oil', size=200, effects_per_flow_hour={'cost': 0.12})]),
Port(
'district',
exports=[
Flow('heat', node='B', size=max_d, fixed_relative_profile=[d / max_d for d in demand]),
],
),
],
converters=[
Converter.boiler('central_boiler', 0.92, Flow('gas', size=200), Flow('heat', node='A', size=100)),
# Pipeline: heat from node A to node B with 5% loss
Converter(
'pipeline', inputs=[pipe_in], outputs=[pipe_out], conversion_factors=[{'heat:A': 0.95, 'heat:B': -1}]
),
Converter.boiler('backup_boiler', 0.85, Flow('oil', size=200), Flow('heat', node='B', size=100)),
],
)
print(f'Total cost: {result.objective:.2f} EUR | Avg: {result.objective / sum(demand) * 100:.2f} ct/kWh')
max_d = max(demand)
# Pipeline flows — referenced in conversion_factors
pipe_in = Flow('heat', node='A', size=60)
pipe_out = Flow('heat', node='B', size=60)
result = optimize(
timesteps=timesteps,
carriers=[Carrier('gas'), Carrier('oil'), Carrier('heat', nodes=['A', 'B'])],
effects=[Effect('cost', unit='EUR', is_objective=True)],
ports=[
Port('gas_grid', imports=[Flow('gas', size=200, effects_per_flow_hour={'cost': 0.05})]),
Port('oil_supply', imports=[Flow('oil', size=200, effects_per_flow_hour={'cost': 0.12})]),
Port(
'district',
exports=[
Flow('heat', node='B', size=max_d, fixed_relative_profile=[d / max_d for d in demand]),
],
),
],
converters=[
Converter.boiler('central_boiler', 0.92, Flow('gas', size=200), Flow('heat', node='A', size=100)),
# Pipeline: heat from node A to node B with 5% loss
Converter(
'pipeline', inputs=[pipe_in], outputs=[pipe_out], conversion_factors=[{'heat:A': 0.95, 'heat:B': -1}]
),
Converter.boiler('backup_boiler', 0.85, Flow('oil', size=200), Flow('heat', node='B', size=100)),
],
)
print(f'Total cost: {result.objective:.2f} EUR | Avg: {result.objective / sum(demand) * 100:.2f} ct/kWh')
Running HiGHS 1.13.1 (git hash: 1d267d9): Copyright (c) 2026 under MIT licence terms LP linopy-problem-wad8pl94 has 630 rows; 270 cols; 922 nonzeros Coefficient ranges: Matrix [5e-02, 1e+00] Cost [1e+00, 1e+00] Bound [0e+00, 0e+00] RHS [3e+01, 2e+02] Presolving model 0 rows, 24 cols, 0 nonzeros 0s 0 rows, 0 cols, 0 nonzeros 0s Presolve reductions: rows 0(-630); columns 0(-270); nonzeros 0(-922) - Reduced to empty Performed postsolve Solving the original LP from the solution after postsolve Model name : linopy-problem-wad8pl94 Model status : Optimal Objective value : 1.1033364917e+02 P-D objective error : 0.0000000000e+00 HiGHS run time : 0.00 Total cost: 110.33 EUR | Avg: 7.93 ct/kWh
Results¶
In [4]:
Copied!
times = result.flow_rates.coords['time'].values
pipeline = result.flow_rate('pipeline(heat:B)').values
backup = result.flow_rate('backup_boiler(heat:B)').values
fig = go.Figure()
fig.add_trace(go.Scatter(x=times, y=pipeline, name='pipeline (from A)', line_shape='hv', stackgroup='s'))
fig.add_trace(go.Scatter(x=times, y=backup, name='backup (local)', line_shape='hv', stackgroup='s'))
fig.add_trace(go.Scatter(x=times, y=demand, name='demand', mode='markers', marker={'color': 'black', 'size': 4}))
fig.update_layout(
title='Heat Supply at Node B',
yaxis_title='kW',
height=350,
margin={'l': 50, 'r': 20, 't': 40, 'b': 20},
template='plotly_white',
)
fig
times = result.flow_rates.coords['time'].values
pipeline = result.flow_rate('pipeline(heat:B)').values
backup = result.flow_rate('backup_boiler(heat:B)').values
fig = go.Figure()
fig.add_trace(go.Scatter(x=times, y=pipeline, name='pipeline (from A)', line_shape='hv', stackgroup='s'))
fig.add_trace(go.Scatter(x=times, y=backup, name='backup (local)', line_shape='hv', stackgroup='s'))
fig.add_trace(go.Scatter(x=times, y=demand, name='demand', mode='markers', marker={'color': 'black', 'size': 4}))
fig.update_layout(
title='Heat Supply at Node B',
yaxis_title='kW',
height=350,
margin={'l': 50, 'r': 20, 't': 40, 'b': 20},
template='plotly_white',
)
fig
Insights¶
- Pipeline-first dispatch: the optimizer maximizes cheap central supply through the pipeline
- Backup only at peaks: the expensive local boiler only runs when demand exceeds pipeline capacity (~57 kW delivered, 60 kW input at 95%)
- Transmission loss: 5% of heat is lost in the pipeline — the central boiler produces more than what arrives at B
- Use case: district heating networks, industrial sites with remote process heat, campus energy systems