Status¶
Real boilers can't modulate freely between 0 and rated power: there's a minimum throughput when running, an ignition cost every time you fire it up, and operational practice mandates a minimum on/off duration to avoid wear from short-cycling.
You'll meet Status — a binary on/off switch attached to a Flow. Together with a non-zero relative_rate_min, it produces semi-continuous behavior: the flow is either zero or in [min, max] × size.
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, Status, Storage, optimize
pio.renderers.default = 'notebook_connected'
1. Add a Status to the boiler¶
We start from the T2 setup (workshop + tank + TOU gas) and tighten the boiler's operating envelope:
relative_rate_min=0.4— when on, the boiler must produce ≥40 % of its rated thermal output (48 kW).Status(uptime_min=2, downtime_min=1)— once on, must stay on ≥2 h; once off, ≥1 h.effects_per_startup={'cost': 0.50}— €0.50 charged every time the boiler ignites.
The tank is what makes this feasible at all: when demand is below 48 kW, the boiler either turns off entirely or dumps the excess into the tank.
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])
peak = 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=1000, effects_per_flow_hour={'cost': gas_price}),
],
),
Port(
id='workshop',
exports=[
Flow(carrier='heat', size=peak, fixed_relative_profile=[d / peak for d in demand]),
],
),
],
converters=[
Converter.boiler(
'boiler',
thermal_efficiency=0.9,
fuel_flow=Flow(carrier='gas', size=200),
thermal_flow=Flow(
carrier='heat',
size=120,
relative_rate_min=0.4,
status=Status(
uptime_min=2,
downtime_min=1,
effects_per_startup={'cost': 0.50},
),
),
),
],
storages=[
Storage(
id='tank',
capacity=300,
charging=Flow(carrier='heat', size=80),
discharging=Flow(carrier='heat', size=80),
eta_charge=0.98,
eta_discharge=0.98,
relative_loss_per_hour=0.005,
),
],
objective='cost',
)
print(f'Total cost: {result.objective:.2f} EUR')
Total cost: 84.16 EUR
2. Read the result¶
Two new variables appear in result.solution:
flow--on— binary, 1 when the flow is producingflow--startup— 1 in the timestep a flow turns on; sum gives total ignitions
Pick them with .sel(flow='boiler(heat)'). The plot below stacks the on/off bar with the heat output rate.
on = result.solution['flow--on'].sel(flow='boiler(heat)')
startups = int(result.solution['flow--startup'].sel(flow='boiler(heat)').sum().values)
times = result.flow_rates.coords['time'].values
heat = result.flow_rate('boiler(heat)').values
level = result.storage_level('tank')
print(f'Boiler ran {int(on.sum().values)}/{n} hours, with {startups} startups')
df = pd.concat(
[
pd.DataFrame({'time': times, 'value': heat, 'panel': 'boiler heat (kW)'}),
pd.DataFrame({'time': times, 'value': on.values, 'panel': 'boiler on/off'}),
pd.DataFrame({'time': level.coords['time'].values, 'value': level.values, 'panel': 'tank level (kWh)'}),
],
ignore_index=True,
)
fig = px.line(df, x='time', y='value', facet_row='panel', line_shape='hv', height=520)
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
Boiler ran 17/24 hours, with 2 startups
Notice how the boiler runs in continuous blocks rather than modulating finely with demand. When on, it sits at or above 48 kW; the surplus during low-demand hours fills the tank, which then carries the workshop through the off blocks. The startup cost biases the optimizer toward fewer, longer runs.
Try relative_rate_min=0.0 and re-run: the binary is still there, but with no minimum-load floor the boiler degenerates to free modulation and the constraint loses its bite.
Recap¶
Status turns a flow into a semi-continuous variable: zero or in the [min, max] × size band. Pair it with uptime_min / downtime_min for unit-commitment realism, and effects_per_startup / effects_per_running_hour for the cost of cycling.
Next: Piecewise Conversion — replace a single efficiency with a piecewise-linear curve, and gate the whole curve with Status.