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_minimum, 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_minimum=0.4— when on, the boiler must produce ≥40 % of its rated thermal output (48 kW).Status(min_uptime=2, min_downtime=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('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,
relative_minimum=0.4,
status=Status(
min_uptime=2,
min_downtime=1,
effects_per_startup={'cost': 0.50},
),
),
),
],
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
MIP linopy-problem-20d7_pfs has 646 rows; 341 cols; 1332 nonzeros; 72 integer variables (72 binary)
Coefficient ranges:
Matrix [4e-02, 1e+02]
Cost [1e+00, 1e+00]
Bound [1e+00, 2e+01]
RHS [1e+00, 1e+03]
Presolving model
305 rows, 214 cols, 820 nonzeros 0s
279 rows, 188 cols, 808 nonzeros 0s
Presolve reductions: rows 279(-367); columns 188(-153); nonzeros 808(-524)
Solving MIP model with:
279 rows
188 cols (68 binary, 0 integer, 0 implied int., 120 continuous, 0 domain fixed)
808 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
0 0 0 0.00% -91.64444444 inf inf 0 0 0 0 0.0s
0 0 0 0.00% 82.86960888 inf inf 0 0 0 105 0.0s
R 0 0 0 0.00% 83.78724122 87.33337405 4.06% 129 24 0 154 0.0s
L 0 0 0 0.00% 84.13757613 84.16214313 0.03% 654 78 12 358 0.1s
L 0 0 0 0.00% 84.14891608 84.16214313 0.02% 680 81 12 663 0.2s
79.4% inactive integer columns, restarting
Model after restart has 55 rows, 63 cols (2 bin., 0 int., 2 impl., 59 cont., 0 dom.fix.), and 160 nonzeros
0 0 0 0.00% 84.14891608 84.16214313 0.02% 9 0 0 715 0.2s
0 0 0 0.00% 84.14891608 84.16214313 0.02% 9 9 0 745 0.2s
1 0 1 100.00% 84.15862279 84.16214313 0.00% 26 10 0 750 0.2s
Solving report
Model linopy-problem-20d7_pfs
Status Optimal
Primal bound 84.1621431255
Dual bound 84.1586227905
Gap 0.00418% (tolerance: 0.01%)
P-D integral 0.00457683070004
Solution status feasible
84.1621431255 (objective)
0 (bound viol.)
0 (int. viol.)
0 (row viol.)
Timing 0.17
Max sub-MIP depth 4
Nodes 1
Repair LPs 0
LP iterations 750
0 (strong br.)
266 (separation)
338 (heuristics)
Total cost: 84.16 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(
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_minimum=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 min_uptime / min_downtime 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.