Effects¶
Real systems care about more than cost. CO₂, primary energy, water use — and sometimes you want to price one of those (a carbon tax) or cap it (a regulation).
Effect is fluxopt's bookkeeping primitive. Every flow can contribute to as many Effects as you list. One drives the objective; the rest are tracked, bounded, or fed into one another via contribution_from.
You'll set up a workshop with two heat sources — a gas boiler (cheap, dirty) and an H₂ boiler (expensive, clean) — and watch the optimizer pick between them as the CO₂ regime changes.
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, optimize
pio.renderers.default = 'notebook_connected'
1. Track CO₂ alongside cost — with a carbon price¶
We declare two Effects:
co2— pure tracker, kgCO₂.cost— euros, plus a contribution fromco2at €0.10 per kg viacontribution_from={'co2': 0.10}. (Roughly the EU ETS rate, ~€100/tCO₂.)
Each fuel flow gets two coefficients on effects_per_flow_hour:
| fuel | cost (€/kWh) | CO₂ (kg/kWh) |
|---|---|---|
| gas | 0.05 | 0.20 |
| H₂ | 0.10 | 0.00 |
Minimizing cost internalizes the carbon price. Gas's effective cost becomes 0.05 + 0.10 × 0.20 = €0.07/kWh of fuel — still below H₂'s €0.10, so gas wins. The reported cost is what the business actually pays: fuel + carbon allowances.
n = 24
timesteps = [datetime(2024, 1, 15) + timedelta(hours=h) for h in range(n)]
demand = np.array([20, 20, 25, 30, 35, 50, 60, 70, 75, 75, 70, 65, 60, 55, 50, 45, 40, 35, 50, 60, 65, 55, 40, 25])
pd.DataFrame({'demand (kW)': demand}, index=pd.Index(range(n), name='hour')).T
| hour | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | ... | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| demand (kW) | 20 | 20 | 25 | 30 | 35 | 50 | 60 | 70 | 75 | 75 | ... | 50 | 45 | 40 | 35 | 50 | 60 | 65 | 55 | 40 | 25 |
1 rows × 24 columns
result = optimize(
timesteps=timesteps,
carriers=[Carrier('gas'), Carrier('h2'), Carrier('heat')],
effects=[
Effect('co2', unit='kgCO2'),
Effect('cost', unit='EUR', contribution_from={'co2': 0.10}),
],
ports=[
Port('gas_grid', imports=[Flow('gas', size=200, effects_per_flow_hour={'cost': 0.05, 'co2': 0.20})]),
Port('h2_grid', imports=[Flow('h2', size=200, effects_per_flow_hour={'cost': 0.10})]),
Port(
'workshop',
exports=[Flow('heat', size=float(demand.max()), fixed_relative_profile=demand / demand.max())],
),
],
converters=[
Converter.boiler(
'gas_boiler',
thermal_efficiency=0.95,
fuel_flow=Flow('gas', size=100),
thermal_flow=Flow('heat', size=80),
),
Converter.boiler(
'h2_boiler',
thermal_efficiency=0.92,
fuel_flow=Flow('h2', size=100),
thermal_flow=Flow('heat', size=80),
),
],
objective_effects='cost',
)
result.effect_totals.to_dataframe(name='value')
Running HiGHS 1.13.1 (git hash: 1d267d9): Copyright (c) 2026 under MIT licence terms LP linopy-problem-d84fc4nt has 510 rows; 246 cols; 826 nonzeros Coefficient ranges: Matrix [5e-02, 1e+00] Cost [1e+00, 1e+00] Bound [0e+00, 0e+00] RHS [2e+01, 2e+02] Presolving model 0 rows, 24 cols, 0 nonzeros 0s 0 rows, 0 cols, 0 nonzeros 0s Presolve reductions: rows 0(-510); columns 0(-246); nonzeros 0(-826) - Reduced to empty Performed postsolve Solving the original LP from the solution after postsolve Model name : linopy-problem-d84fc4nt Model status : Optimal Objective value : 8.6578947368e+01 P-D objective error : 1.6319506775e-16 HiGHS run time : 0.00
| value | |
|---|---|
| effect | |
| co2 | 247.368421 |
| cost | 86.578947 |
| penalty | -0.000000 |
Gas dominates. The cost total breaks down into ~€62 of pure fuel plus ~€25 of carbon allowances (0.10 × 247 kg). At today's carbon price gas is still the rational choice — pricing alone isn't enough to drive H₂ adoption.
2. Cap the CO₂¶
A regulator wants emissions below what the price alone delivers — they impose a hard cap. We keep contribution_from active (the carbon price is still real), and add Effect('co2', maximum=...):
Effect('co2', unit='kgCO2', maximum=100)
The optimizer keeps minimizing total expenditure (fuel + allowances), but now also has to fit the demand under 100 kgCO₂ for the day — about 40 % of the unconstrained footprint. Gas runs up to the cap; H₂ covers the rest.
def solve_with_cap(co2_max):
return optimize(
timesteps=timesteps,
carriers=[Carrier('gas'), Carrier('h2'), Carrier('heat')],
effects=[
Effect('co2', unit='kgCO2', maximum=co2_max),
Effect('cost', unit='EUR', contribution_from={'co2': 0.10}),
],
ports=[
Port('gas_grid', imports=[Flow('gas', size=200, effects_per_flow_hour={'cost': 0.05, 'co2': 0.20})]),
Port('h2_grid', imports=[Flow('h2', size=200, effects_per_flow_hour={'cost': 0.10})]),
Port(
'workshop',
exports=[Flow('heat', size=float(demand.max()), fixed_relative_profile=demand / demand.max())],
),
],
converters=[
Converter.boiler(
'gas_boiler',
thermal_efficiency=0.95,
fuel_flow=Flow('gas', size=100),
thermal_flow=Flow('heat', size=80),
),
Converter.boiler(
'h2_boiler',
thermal_efficiency=0.92,
fuel_flow=Flow('h2', size=100),
thermal_flow=Flow('heat', size=80),
),
],
objective_effects='cost',
log_to_console=False,
)
solve_with_cap(100).effect_totals.to_dataframe(name='value')
| value | |
|---|---|
| effect | |
| co2 | 100.000000 |
| cost | 111.086957 |
| penalty | -0.000000 |
3. Sweep the cap → cost-vs-CO₂ frontier¶
Tightening the cap forces more H₂ in. Each cap value gives one point on the cost–CO₂ Pareto frontier — total business expenditure (fuel + allowances) versus actual emissions.
caps = [10, 50, 100, 150, 200, 247]
rows = []
for cap in caps:
r = solve_with_cap(cap)
rows.append(
{
'cap (kg)': cap,
'co2 (kg)': float(r.effect_totals.sel(effect='co2')),
'cost (€)': float(r.effect_totals.sel(effect='cost')),
}
)
frontier = pd.DataFrame(rows).set_index('cap (kg)')
frontier
| co2 (kg) | cost (€) | |
|---|---|---|
| cap (kg) | ||
| 10 | 10.0 | 126.054348 |
| 50 | 50.0 | 119.402174 |
| 100 | 100.0 | 111.086957 |
| 150 | 150.0 | 102.771739 |
| 200 | 200.0 | 94.456522 |
| 247 | 247.0 | 86.640217 |
fig = px.line(frontier, x='co2 (kg)', y='cost (€)', markers=True, height=320)
fig.update_traces(line_color='#16a34a')
fig.update_layout(template='plotly_white', margin={'l': 50, 'r': 20, 't': 20, 'b': 40})
fig
4. Where does each total come from?¶
Effects are aggregates — the totals don't say which flows drove them. result.stats.effect_contributions decomposes each effect into per-flow contributions, with cross-effects (like the carbon allowance routed from co2 back into cost via contribution_from) attributed to the originating flow.
result_100 = solve_with_cap(100)
contrib = result_100.stats.effect_contributions['total']
contrib.transpose('contributor', 'effect').to_pandas().round(2)
| effect | co2 | cost | penalty |
|---|---|---|---|
| contributor | |||
| gas_grid(gas) | 100.0 | 35.00 | 0.0 |
| h2_grid(h2) | 0.0 | 76.09 | 0.0 |
| workshop(heat) | 0.0 | 0.00 | 0.0 |
| gas_boiler(gas) | 0.0 | 0.00 | 0.0 |
| gas_boiler(heat) | 0.0 | 0.00 | 0.0 |
| h2_boiler(h2) | 0.0 | 0.00 | 0.0 |
| h2_boiler(heat) | 0.0 | 0.00 | 0.0 |
Reading down the columns: cost totals to ~€111, split into ~€35 from gas (fuel + €10 carbon allowance for its 100 kg) and ~€76 from H₂ (pure fuel). The co2 column shows gas owns the entire 100 kg footprint — H₂ is zero-carbon by construction.
A stacked bar makes the split visual:
df = contrib.sel(effect=['cost', 'co2']).to_dataframe('value').reset_index()
df = df[df['value'].abs() > 1e-6]
fig = px.bar(df, x='effect', y='value', color='contributor', height=320)
fig.update_yaxes(title='')
fig.update_layout(template='plotly_white', barmode='stack', margin={'l': 50, 'r': 20, 't': 20, 'b': 40})
fig
Recap¶
Effect is fluxopt's multi-objective hook:
- Track several quantities by listing them in
effectsand adding coefficients on each flow'seffects_per_flow_hour. - Price one effect into another via
contribution_from={'source': τ}— natural for a tax. Reported objective then includes the carbon money, mirroring real cap-and-trade economics. - Cap an effect via
Effect(name, maximum=...)— a hard constraint when the price alone doesn't deliver enough decarbonization. - Sweep the cap to trace a Pareto frontier with both axes in real units.
Effect also supports per-period and per-hour bounds (maximum_per_period, maximum_per_hour) — useful when a cap applies year-by-year or as a rate, not as a single horizon total.
Next: Sizing — let the optimizer pick the boiler and tank capacities given investment costs.