Piecewise Effects¶
Model non-linear investment costs with economies of scale and discrete size tiers.
This notebook demonstrates:
- PiecewiseEffects: Non-linear cost functions for investments
- Gaps between pieces: Representing discrete size tiers (unavailable sizes)
- How the optimizer selects from available size options
In [1]:
Copied!
import numpy as np
import pandas as pd
import flixopt as fx
fx.CONFIG.notebook()
import numpy as np import pandas as pd import flixopt as fx fx.CONFIG.notebook()
Out[1]:
flixopt.config.CONFIG
The Problem: Discrete Size Tiers¶
Real equipment often comes in discrete sizes with gaps between options:
| Tier | Size Range | Cost per kWh | Notes |
|---|---|---|---|
| Small | 50-100 kWh | 0.20 €/kWh | Residential units |
| Gap | 100-200 kWh | unavailable | No products in this range |
| Medium | 200-400 kWh | 0.12 €/kWh | Commercial units |
| Gap | 400-500 kWh | unavailable | No products in this range |
| Large | 500-800 kWh | 0.06 €/kWh | Industrial units |
The gaps represent size ranges where no products are available from manufacturers.
Define the Cost Curve with Gaps¶
Each piece defines a size tier. Gaps between pieces are forbidden zones.
In [2]:
Copied!
# Piecewise costs with gaps between tiers
# Cost values are CUMULATIVE at each breakpoint
piecewise_costs = fx.PiecewiseEffects(
piecewise_origin=fx.Piecewise(
[
fx.Piece(start=50, end=100), # Small tier: 50-100 kWh
fx.Piece(start=200, end=400), # Medium tier: 200-400 kWh (gap: 100-200)
fx.Piece(start=500, end=800), # Large tier: 500-800 kWh (gap: 400-500)
]
),
piecewise_shares={
'costs': fx.Piecewise(
[
fx.Piece(start=10, end=20), # 50kWh=10€, 100kWh=20€ → 0.20 €/kWh
fx.Piece(start=24, end=48), # 200kWh=24€, 400kWh=48€ → 0.12 €/kWh
fx.Piece(start=30, end=48), # 500kWh=30€, 800kWh=48€ → 0.06 €/kWh
]
)
},
)
print('Available size tiers:')
print(' Small: 50-100 kWh at 0.20 €/kWh')
print(' Medium: 200-400 kWh at 0.12 €/kWh')
print(' Large: 500-800 kWh at 0.06 €/kWh')
# Piecewise costs with gaps between tiers # Cost values are CUMULATIVE at each breakpoint piecewise_costs = fx.PiecewiseEffects( piecewise_origin=fx.Piecewise( [ fx.Piece(start=50, end=100), # Small tier: 50-100 kWh fx.Piece(start=200, end=400), # Medium tier: 200-400 kWh (gap: 100-200) fx.Piece(start=500, end=800), # Large tier: 500-800 kWh (gap: 400-500) ] ), piecewise_shares={ 'costs': fx.Piecewise( [ fx.Piece(start=10, end=20), # 50kWh=10€, 100kWh=20€ → 0.20 €/kWh fx.Piece(start=24, end=48), # 200kWh=24€, 400kWh=48€ → 0.12 €/kWh fx.Piece(start=30, end=48), # 500kWh=30€, 800kWh=48€ → 0.06 €/kWh ] ) }, ) print('Available size tiers:') print(' Small: 50-100 kWh at 0.20 €/kWh') print(' Medium: 200-400 kWh at 0.12 €/kWh') print(' Large: 500-800 kWh at 0.06 €/kWh')
Available size tiers: Small: 50-100 kWh at 0.20 €/kWh Medium: 200-400 kWh at 0.12 €/kWh Large: 500-800 kWh at 0.06 €/kWh
In [3]:
Copied!
timesteps = pd.date_range('2024-01-01', periods=24, freq='h')
# Electricity price: cheap at night, expensive during day
elec_price = np.array(
[
0.05,
0.05,
0.05,
0.05,
0.05,
0.05, # 00-06: night (cheap)
0.15,
0.20,
0.25,
0.25,
0.20,
0.15, # 06-12: morning
0.15,
0.20,
0.25,
0.30,
0.30,
0.25, # 12-18: afternoon (expensive)
0.20,
0.15,
0.10,
0.08,
0.06,
0.05, # 18-24: evening
]
)
demand = np.full(24, 100) # 100 kW constant demand
timesteps = pd.date_range('2024-01-01', periods=24, freq='h') # Electricity price: cheap at night, expensive during day elec_price = np.array( [ 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, # 00-06: night (cheap) 0.15, 0.20, 0.25, 0.25, 0.20, 0.15, # 06-12: morning 0.15, 0.20, 0.25, 0.30, 0.30, 0.25, # 12-18: afternoon (expensive) 0.20, 0.15, 0.10, 0.08, 0.06, 0.05, # 18-24: evening ] ) demand = np.full(24, 100) # 100 kW constant demand
Simple Arbitrage Scenario¶
A battery arbitrages between cheap night and expensive day electricity.
Build and Solve the Model¶
In [4]:
Copied!
fs = fx.FlowSystem(timesteps)
fs.add_elements(
fx.Bus('Elec'),
fx.Effect('costs', '€', is_standard=True, is_objective=True),
# Grid with time-varying price
fx.Source('Grid', outputs=[fx.Flow('Elec', bus='Elec', size=500, effects_per_flow_hour=elec_price)]),
# Battery with PIECEWISE investment cost (discrete tiers)
fx.Storage(
'Battery',
charging=fx.Flow('charge', bus='Elec', size=fx.InvestParameters(maximum_size=400)),
discharging=fx.Flow('discharge', bus='Elec', size=fx.InvestParameters(maximum_size=400)),
capacity_in_flow_hours=fx.InvestParameters(
piecewise_effects_of_investment=piecewise_costs,
minimum_size=0,
maximum_size=800,
),
eta_charge=0.95,
eta_discharge=0.95,
initial_charge_state=0,
),
fx.Sink('Demand', inputs=[fx.Flow('Elec', bus='Elec', size=1, fixed_relative_profile=demand)]),
)
fs.optimize(fx.solvers.HighsSolver());
fs = fx.FlowSystem(timesteps) fs.add_elements( fx.Bus('Elec'), fx.Effect('costs', '€', is_standard=True, is_objective=True), # Grid with time-varying price fx.Source('Grid', outputs=[fx.Flow('Elec', bus='Elec', size=500, effects_per_flow_hour=elec_price)]), # Battery with PIECEWISE investment cost (discrete tiers) fx.Storage( 'Battery', charging=fx.Flow('charge', bus='Elec', size=fx.InvestParameters(maximum_size=400)), discharging=fx.Flow('discharge', bus='Elec', size=fx.InvestParameters(maximum_size=400)), capacity_in_flow_hours=fx.InvestParameters( piecewise_effects_of_investment=piecewise_costs, minimum_size=0, maximum_size=800, ), eta_charge=0.95, eta_discharge=0.95, initial_charge_state=0, ), fx.Sink('Demand', inputs=[fx.Flow('Elec', bus='Elec', size=1, fixed_relative_profile=demand)]), ) fs.optimize(fx.solvers.HighsSolver());
Visualize the Cost Curve¶
The plot shows the three discrete tiers with gaps between them.
In [5]:
Copied!
piecewise_costs.plot(title='Battery Investment Cost (Discrete Tiers)')
piecewise_costs.plot(title='Battery Investment Cost (Discrete Tiers)')
Out[5]:
Results: Which Tier Was Selected?¶
In [6]:
Copied!
battery_size = fs.solution['Battery|size'].item()
total_cost = fs.solution['costs'].item()
# Determine which tier was selected
if battery_size < 1:
tier = 'None'
elif battery_size <= 100:
tier = 'Small (50-100 kWh)'
elif battery_size <= 400:
tier = 'Medium (200-400 kWh)'
else:
tier = 'Large (500-800 kWh)'
print(f'Selected tier: {tier}')
print(f'Battery size: {battery_size:.0f} kWh')
print(f'Total cost: {total_cost:.1f} €')
battery_size = fs.solution['Battery|size'].item() total_cost = fs.solution['costs'].item() # Determine which tier was selected if battery_size < 1: tier = 'None' elif battery_size <= 100: tier = 'Small (50-100 kWh)' elif battery_size <= 400: tier = 'Medium (200-400 kWh)' else: tier = 'Large (500-800 kWh)' print(f'Selected tier: {tier}') print(f'Battery size: {battery_size:.0f} kWh') print(f'Total cost: {total_cost:.1f} €')
Selected tier: Large (500-800 kWh) Battery size: 800 kWh Total cost: 249.0 €
Storage Operation¶
In [7]:
Copied!
fs.statistics.plot.balance('Elec')
fs.statistics.plot.balance('Elec')
Out[7]:
Best Practice: PiecewiseEffects with Gaps¶
fx.PiecewiseEffects(
piecewise_origin=fx.Piecewise([
fx.Piece(start=50, end=100), # Tier 1
fx.Piece(start=200, end=400), # Tier 2 (gap: 100-200 forbidden)
]),
piecewise_shares={
'costs': fx.Piecewise([
fx.Piece(start=10, end=20), # Cumulative cost at tier 1 boundaries
fx.Piece(start=24, end=48), # Cumulative cost at tier 2 boundaries
])
},
)
Key points:
- Gaps between pieces = forbidden size ranges
- Cost values are cumulative at each boundary
- Use when equipment comes in discrete tiers
Previous: Piecewise Conversion¶
See 06b-piecewise-conversion for modeling minimum load constraints with PiecewiseConversion + StatusParameters.