Piecewise Conversion¶
Real converters don't have a single efficiency. A condensing boiler is most efficient at mid-load, worse at low load (cycling/standby losses) and worse at high load (hotter flue). A heat pump's COP varies with ambient temperature.
You'll meet PiecewiseConversion — a piecewise-linear coupling between two or more flows. The optimizer picks an operating point on the curve at every timestep. We'll pair it with the Status you saw in T3 so the boiler can also turn off.
from datetime import datetime, timedelta
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import plotly.io as pio
from fluxopt import Carrier, Converter, Effect, Flow, PiecewiseConversion, Port, Status, optimize
pio.renderers.default = 'notebook_connected'
1. The curve¶
A 100 kW condensing boiler. Real units have a minimum modulation — below ~30 % load they cycle off entirely, because steady combustion isn't viable. The manufacturer datasheet gives efficiency at three load points:
| Gas (kW) | Heat (kW) | η = heat/gas |
|---|---|---|
| 30 (min modulation) | 27.0 | 0.90 — low fire, cycling losses |
| 70 | 66.5 | 0.95 — sweet spot, full condensing |
| 100 (rated) | 85.0 | 0.85 — hot flue, less condensing |
Each row is a breakpoint — (gas, heat) at one operating point. The optimizer linearly interpolates between adjacent breakpoints. Note the absence of a (0, 0) point — that's deliberate. "Off" comes from Status instead, which is what makes Status structurally meaningful here (without it, the boiler could never be off).
gas_bp = [30, 70, 100]
heat_bp = [27.0, 66.5, 85.0]
etas = [h / g for g, h in zip(gas_bp, heat_bp, strict=True)]
fig = px.line(x=gas_bp, y=heat_bp, markers=True, height=320, labels={'x': 'gas input (kW)', 'y': 'heat output (kW)'})
for g, h, eta in zip(gas_bp, heat_bp, etas, strict=True):
fig.add_annotation(
x=g,
y=h,
text=f'η = {eta:.2f}',
showarrow=False,
yshift=16,
font={'color': '#16a34a', 'size': 12},
)
fig.update_layout(template='plotly_white', margin={'l': 50, 'r': 20, 't': 10, 'b': 40})
fig
2. Solve¶
Two ingredients compose into the converter:
- The curve itself, in the dict form
{flow_short_id: breakpoints}— every listed flow shares the same interpolation weights. - A
Statuson the curve. In T3 we putStatuson a singleFlow(a flow-level switch — one binary per flow). On aPiecewiseConversionit's a component-level switch — one binary gates every flow on the curve at once. When off, all curve flows are pinned to zero (the boiler stops); when on, the operating point sits on the curve (minimum 27 kW heat from 30 kW gas).
A backup heat source at €0.20/kWh (4× the gas-derived cost) covers low-demand hours when the boiler is off — without it, the model would be infeasible whenever demand drops below 27 kW.
n = 24
timesteps = [datetime(2024, 1, 15) + timedelta(hours=h) for h in range(n)]
demand = np.array([10, 10, 15, 20, 25, 35, 50, 65, 70, 60, 50, 45, 40, 35, 30, 25, 20, 30, 55, 70, 78, 75, 60, 35])
result = optimize(
timesteps=timesteps,
carriers=[Carrier('gas'), Carrier('heat')],
effects=[Effect('cost', unit='EUR')],
ports=[
Port('gas_grid', imports=[Flow('gas', size=500, effects_per_flow_hour={'cost': 0.05})]),
Port('backup', imports=[Flow('heat', size=200, effects_per_flow_hour={'cost': 0.20})]),
Port(
'workshop', exports=[Flow('heat', size=float(demand.max()), fixed_relative_profile=demand / demand.max())]
),
],
converters=[
Converter(
'boiler',
inputs=[Flow('gas', short_id='fuel')],
outputs=[Flow('heat', size=100)],
conversion=PiecewiseConversion(
{'fuel': gas_bp, 'heat': heat_bp},
status=Status(min_uptime=2, effects_per_startup={'cost': 2.0}),
),
),
],
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
MIP linopy-problem-4i8hz215 has 552 rows; 364 cols; 1266 nonzeros; 120 integer variables (120 binary)
Coefficient ranges:
Matrix [5e-02, 1e+02]
Cost [1e+00, 1e+00]
Bound [1e+00, 2e+01]
RHS [1e+00, 5e+02]
Presolving model
236 rows, 155 cols, 586 nonzeros 0s
182 rows, 118 cols, 537 nonzeros 0s
156 rows, 87 cols, 384 nonzeros 0s
116 rows, 78 cols, 312 nonzeros 0s
116 rows, 78 cols, 312 nonzeros 0s
Presolve reductions: rows 116(-436); columns 78(-286); nonzeros 312(-954)
Solving MIP model with:
116 rows
78 cols (47 binary, 0 integer, 8 implied int., 23 continuous, 0 domain fixed)
312 nonzeros
Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero
Nodes | B&B Tree | Objective Bounds | Dynamic Constraints | Work
Src Proc. InQueue | Leaves Expl. | BestBound BestSol Gap | Cuts InLp Confl. | LpIters Time
J 0 0 0 0.00% -inf 192.964557 Large 0 0 0 0 0.0s
T 0 0 0 0.00% 70.50632911 76.79045501 8.18% 0 0 0 39 0.0s
1 0 1 100.00% 76.79045501 76.79045501 0.00% 0 0 0 39 0.0s
Solving report
Model linopy-problem-4i8hz215
Status Optimal
Primal bound 76.790455012
Dual bound 76.790455012
Gap 0% (tolerance: 0.01%)
P-D integral 0.000597881305059
Solution status feasible
76.790455012 (objective)
0 (bound viol.)
1.33226762955e-15 (int. viol.)
0 (row viol.)
Timing 0.01
Max sub-MIP depth 0
Nodes 1
Repair LPs 0
LP iterations 39
0 (strong br.)
0 (separation)
0 (heuristics)
Total cost: 76.79 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 views:
- Operating points — each timestep's
(gas, heat)overlaid on the curve. With the status binary in play, points cluster on the efficient segments and a few sit at the origin (boiler off, backup serves demand). - Time series — boiler heat / backup heat / demand, plus the boiler's on/off binary.
Because the switch is component-level, the on/off variable now lives at result.solution['component--on'] (dim: component) — not flow--on (dim: flow) like T3. Same Status fields, different scope, different output array. The same applies to Storage(..., status=Status(...)): a storage's status gates its charging and discharging flows together.
gas_rate = result.flow_rate('boiler(fuel)').values
heat_rate = result.flow_rate('boiler(heat)').values
backup_rate = result.flow_rate('backup(heat)').values
on = result.solution['component--on'].sel(component='boiler').values
fig = go.Figure()
fig.add_trace(go.Scatter(x=gas_bp, y=heat_bp, mode='lines+markers', name='curve', line_color='#636efa'))
fig.add_trace(
go.Scatter(
x=gas_rate,
y=heat_rate,
mode='markers',
name='operating points',
marker={'symbol': 'x', 'size': 9, 'color': '#ef553b'},
)
)
fig.update_layout(
height=320,
margin={'l': 50, 'r': 20, 't': 10, 'b': 40},
template='plotly_white',
xaxis_title='gas input (kW)',
yaxis_title='heat output (kW)',
)
fig
times = result.flow_rates.coords['time'].values
eta_t = np.where(gas_rate > 1e-6, heat_rate / np.maximum(gas_rate, 1e-9), np.nan)
df = pd.concat(
[
pd.DataFrame({'time': times, 'value': heat_rate, 'panel': 'heat (kW)', 'series': 'boiler'}),
pd.DataFrame({'time': times, 'value': backup_rate, 'panel': 'heat (kW)', 'series': 'backup'}),
pd.DataFrame({'time': times, 'value': demand, 'panel': 'heat (kW)', 'series': 'demand'}),
pd.DataFrame({'time': times, 'value': eta_t, 'panel': 'η', 'series': 'boiler η'}),
pd.DataFrame({'time': times, 'value': on, 'panel': 'boiler on/off', 'series': 'on'}),
],
ignore_index=True,
)
fig = px.line(df, x='time', y='value', color='series', facet_row='panel', line_shape='hv', height=560)
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
Recap¶
PiecewiseConversion replaces a fixed efficiency with a piecewise-linear coupling. It composes with Status to give you the on/off binary at solution['component--on']. The dict form scales to three or more flows simply by adding entries — every flow shares the same interpolation weights, which is how a CHP with load-dependent heat-to-power ratio is modeled.
You've now seen the full vocabulary: Carrier, Flow, Port, Converter, Effect, Storage, Status, PiecewiseConversion. Two more to go: Sizing (capacity optimization) and Investment (build-timing).
Next: Effects — track CO₂ alongside cost, price one effect into another, and trace a Pareto frontier.