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(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': 0.05})]),
Port(id='backup', imports=[Flow(carrier='heat', size=200, effects_per_flow_hour={'cost': 0.20})]),
Port(
id='workshop',
exports=[Flow(carrier='heat', size=float(demand.max()), fixed_relative_profile=demand / demand.max())],
),
],
converters=[
Converter(
id='boiler',
inputs=[Flow(carrier='gas', short_id='fuel')],
outputs=[Flow(carrier='heat', size=100)],
conversion=PiecewiseConversion(
points={'fuel': gas_bp, 'heat': heat_bp},
status=Status(uptime_min=2, effects_per_startup={'cost': 2.0}),
),
),
],
objective='cost',
)
print(f'Total cost: {result.objective:.2f} EUR')
Total cost: 76.79 EUR
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.