Sizing¶
Size a solar heating system - let the optimizer decide equipment sizes.
This notebook introduces:
- InvestParameters: Define investment decisions with size bounds and costs
- Investment costs: Fixed costs and size-dependent costs
- Optimal sizing: Let the optimizer find the best equipment sizes
- Trade-off analysis: Balance investment vs. operating costs
Setup¶
In [1]:
Copied!
import numpy as np
import pandas as pd
import plotly.express as px
import xarray as xr
import flixopt as fx
fx.CONFIG.notebook()
import numpy as np import pandas as pd import plotly.express as px import xarray as xr import flixopt as fx fx.CONFIG.notebook()
Out[1]:
flixopt.config.CONFIG
System Description¶
The swimming pool heating system:
- Solar collectors: Convert solar radiation to heat (size to be optimized)
- Gas boiler: Backup heating when solar is insufficient (existing, 200 kW)
- Buffer tank: Store excess solar heat (size to be optimized)
- Pool: Constant heat demand of 150 kW during operating hours
☀️ Solar ──► [Heat] ◄── Boiler ◄── [Gas]
│
▼
Buffer Tank
│
▼
Pool 🏊
Define Time Horizon and Profiles¶
We model one representative summer week:
In [2]:
Copied!
# One week in summer, hourly
timesteps = pd.date_range('2024-07-15', periods=168, freq='h')
hours = np.arange(168)
hour_of_day = hours % 24
# Solar radiation profile (kW/m² equivalent, simplified)
# Peak around noon, zero at night
solar_profile = np.maximum(0, np.sin((hour_of_day - 6) * np.pi / 12)) * 0.8
solar_profile = np.where((hour_of_day >= 6) & (hour_of_day <= 20), solar_profile, 0)
# Add some cloud variation
np.random.seed(42)
cloud_factor = np.random.uniform(0.6, 1.0, len(timesteps))
solar_profile = solar_profile * cloud_factor
# Pool operates 8am-10pm, constant demand when open
pool_demand = np.where((hour_of_day >= 8) & (hour_of_day <= 22), 150, 50) # kW
print(f'Peak solar: {solar_profile.max():.2f} kW/kW_installed')
print(f'Pool demand: {pool_demand.max():.0f} kW (open), {pool_demand.min():.0f} kW (closed)')
# One week in summer, hourly timesteps = pd.date_range('2024-07-15', periods=168, freq='h') hours = np.arange(168) hour_of_day = hours % 24 # Solar radiation profile (kW/m² equivalent, simplified) # Peak around noon, zero at night solar_profile = np.maximum(0, np.sin((hour_of_day - 6) * np.pi / 12)) * 0.8 solar_profile = np.where((hour_of_day >= 6) & (hour_of_day <= 20), solar_profile, 0) # Add some cloud variation np.random.seed(42) cloud_factor = np.random.uniform(0.6, 1.0, len(timesteps)) solar_profile = solar_profile * cloud_factor # Pool operates 8am-10pm, constant demand when open pool_demand = np.where((hour_of_day >= 8) & (hour_of_day <= 22), 150, 50) # kW print(f'Peak solar: {solar_profile.max():.2f} kW/kW_installed') print(f'Pool demand: {pool_demand.max():.0f} kW (open), {pool_demand.min():.0f} kW (closed)')
Peak solar: 0.76 kW/kW_installed Pool demand: 150 kW (open), 50 kW (closed)
In [3]:
Copied!
# Visualize profiles with plotly - using xarray and faceting
profiles = xr.Dataset(
{
'Solar Profile [kW/kW]': xr.DataArray(solar_profile, dims=['time'], coords={'time': timesteps}),
'Pool Demand [kW]': xr.DataArray(pool_demand, dims=['time'], coords={'time': timesteps}),
}
)
# Convert to long format for faceting
df = profiles.to_dataframe().reset_index().melt(id_vars='time', var_name='variable', value_name='value')
fig = px.line(df, x='time', y='value', facet_col='variable', height=300)
fig.update_yaxes(matches=None, showticklabels=True)
fig.for_each_annotation(lambda a: a.update(text=a.text.split('=')[-1]))
fig
# Visualize profiles with plotly - using xarray and faceting profiles = xr.Dataset( { 'Solar Profile [kW/kW]': xr.DataArray(solar_profile, dims=['time'], coords={'time': timesteps}), 'Pool Demand [kW]': xr.DataArray(pool_demand, dims=['time'], coords={'time': timesteps}), } ) # Convert to long format for faceting df = profiles.to_dataframe().reset_index().melt(id_vars='time', var_name='variable', value_name='value') fig = px.line(df, x='time', y='value', facet_col='variable', height=300) fig.update_yaxes(matches=None, showticklabels=True) fig.for_each_annotation(lambda a: a.update(text=a.text.split('=')[-1])) fig
Define Costs¶
Investment costs are annualized (€/year) to compare with operating costs:
In [4]:
Copied!
# Cost parameters
GAS_PRICE = 0.12 # €/kWh - high gas price makes solar attractive
# Solar collectors: 400 €/kW installed, 20-year lifetime → ~25 €/kW/year annualized
# (simplified, real calculation would include interest rate)
SOLAR_COST_PER_KW = 20 # €/kW/year
# Buffer tank: 50 €/kWh capacity, 30-year lifetime → ~2 €/kWh/year
TANK_COST_PER_KWH = 1.5 # €/kWh/year
# Scale factor: We model 1 week, but costs are annual
# So we scale investment costs to weekly equivalent
WEEKS_PER_YEAR = 52
SOLAR_COST_WEEKLY = SOLAR_COST_PER_KW / WEEKS_PER_YEAR
TANK_COST_WEEKLY = TANK_COST_PER_KWH / WEEKS_PER_YEAR
print(f'Solar cost: {SOLAR_COST_WEEKLY:.3f} €/kW/week')
print(f'Tank cost: {TANK_COST_WEEKLY:.4f} €/kWh/week')
# Cost parameters GAS_PRICE = 0.12 # €/kWh - high gas price makes solar attractive # Solar collectors: 400 €/kW installed, 20-year lifetime → ~25 €/kW/year annualized # (simplified, real calculation would include interest rate) SOLAR_COST_PER_KW = 20 # €/kW/year # Buffer tank: 50 €/kWh capacity, 30-year lifetime → ~2 €/kWh/year TANK_COST_PER_KWH = 1.5 # €/kWh/year # Scale factor: We model 1 week, but costs are annual # So we scale investment costs to weekly equivalent WEEKS_PER_YEAR = 52 SOLAR_COST_WEEKLY = SOLAR_COST_PER_KW / WEEKS_PER_YEAR TANK_COST_WEEKLY = TANK_COST_PER_KWH / WEEKS_PER_YEAR print(f'Solar cost: {SOLAR_COST_WEEKLY:.3f} €/kW/week') print(f'Tank cost: {TANK_COST_WEEKLY:.4f} €/kWh/week')
Solar cost: 0.385 €/kW/week Tank cost: 0.0288 €/kWh/week
Build the System with Investment Options¶
Use InvestParameters to define which sizes should be optimized:
In [5]:
Copied!
flow_system = fx.FlowSystem(timesteps)
flow_system.add_carriers(
fx.Carrier('gas', '#3498db', 'kW'),
fx.Carrier('heat', '#e74c3c', 'kW'),
)
flow_system.add_elements(
# === Buses ===
fx.Bus('Heat', carrier='heat'),
fx.Bus('Gas', carrier='gas'),
# === Effects ===
fx.Effect('costs', '€', 'Total Costs', is_standard=True, is_objective=True),
# === Gas Supply ===
fx.Source(
'GasGrid',
outputs=[fx.Flow('Gas', bus='Gas', size=500, effects_per_flow_hour=GAS_PRICE)],
),
# === Gas Boiler (existing, fixed size) ===
fx.linear_converters.Boiler(
'GasBoiler',
thermal_efficiency=0.92,
thermal_flow=fx.Flow('Heat', bus='Heat', size=200), # 200 kW existing
fuel_flow=fx.Flow('Gas', bus='Gas'),
),
# === Solar Collectors (size to be optimized) ===
fx.Source(
'SolarCollectors',
outputs=[
fx.Flow(
'Heat',
bus='Heat',
# Investment optimization: find optimal size between 0-500 kW
size=fx.InvestParameters(
minimum_size=0,
maximum_size=500,
effects_of_investment_per_size={'costs': SOLAR_COST_WEEKLY},
),
# Solar output depends on radiation profile
fixed_relative_profile=solar_profile,
)
],
),
# === Buffer Tank (size to be optimized) ===
fx.Storage(
'BufferTank',
# Investment optimization: find optimal capacity between 0-2000 kWh
capacity_in_flow_hours=fx.InvestParameters(
minimum_size=0,
maximum_size=2000,
effects_of_investment_per_size={'costs': TANK_COST_WEEKLY},
),
initial_charge_state=0,
eta_charge=0.95,
eta_discharge=0.95,
relative_loss_per_hour=0.01, # 1% loss per hour
charging=fx.Flow('Charge', bus='Heat', size=200),
discharging=fx.Flow('Discharge', bus='Heat', size=200),
),
# === Pool Heat Demand ===
fx.Sink(
'Pool',
inputs=[fx.Flow('Heat', bus='Heat', size=1, fixed_relative_profile=pool_demand)],
),
)
flow_system = fx.FlowSystem(timesteps) flow_system.add_carriers( fx.Carrier('gas', '#3498db', 'kW'), fx.Carrier('heat', '#e74c3c', 'kW'), ) flow_system.add_elements( # === Buses === fx.Bus('Heat', carrier='heat'), fx.Bus('Gas', carrier='gas'), # === Effects === fx.Effect('costs', '€', 'Total Costs', is_standard=True, is_objective=True), # === Gas Supply === fx.Source( 'GasGrid', outputs=[fx.Flow('Gas', bus='Gas', size=500, effects_per_flow_hour=GAS_PRICE)], ), # === Gas Boiler (existing, fixed size) === fx.linear_converters.Boiler( 'GasBoiler', thermal_efficiency=0.92, thermal_flow=fx.Flow('Heat', bus='Heat', size=200), # 200 kW existing fuel_flow=fx.Flow('Gas', bus='Gas'), ), # === Solar Collectors (size to be optimized) === fx.Source( 'SolarCollectors', outputs=[ fx.Flow( 'Heat', bus='Heat', # Investment optimization: find optimal size between 0-500 kW size=fx.InvestParameters( minimum_size=0, maximum_size=500, effects_of_investment_per_size={'costs': SOLAR_COST_WEEKLY}, ), # Solar output depends on radiation profile fixed_relative_profile=solar_profile, ) ], ), # === Buffer Tank (size to be optimized) === fx.Storage( 'BufferTank', # Investment optimization: find optimal capacity between 0-2000 kWh capacity_in_flow_hours=fx.InvestParameters( minimum_size=0, maximum_size=2000, effects_of_investment_per_size={'costs': TANK_COST_WEEKLY}, ), initial_charge_state=0, eta_charge=0.95, eta_discharge=0.95, relative_loss_per_hour=0.01, # 1% loss per hour charging=fx.Flow('Charge', bus='Heat', size=200), discharging=fx.Flow('Discharge', bus='Heat', size=200), ), # === Pool Heat Demand === fx.Sink( 'Pool', inputs=[fx.Flow('Heat', bus='Heat', size=1, fixed_relative_profile=pool_demand)], ), )
Run Optimization¶
In [6]:
Copied!
flow_system.optimize(fx.solvers.HighsSolver(mip_gap=0.01));
flow_system.optimize(fx.solvers.HighsSolver(mip_gap=0.01));
In [7]:
Copied!
solar_size = flow_system.statistics.sizes['SolarCollectors(Heat)'].item()
tank_size = flow_system.statistics.sizes['BufferTank'].item()
print(
f'Optimal sizes: Solar {solar_size:.0f} kW, Tank {tank_size:.0f} kWh (ratio: {tank_size / solar_size:.1f} kWh/kW)'
)
solar_size = flow_system.statistics.sizes['SolarCollectors(Heat)'].item() tank_size = flow_system.statistics.sizes['BufferTank'].item() print( f'Optimal sizes: Solar {solar_size:.0f} kW, Tank {tank_size:.0f} kWh (ratio: {tank_size / solar_size:.1f} kWh/kW)' )
Optimal sizes: Solar 458 kW, Tank 748 kWh (ratio: 1.6 kWh/kW)
Visualize Sizes¶
In [8]:
Copied!
flow_system.statistics.plot.sizes()
flow_system.statistics.plot.sizes()
Out[8]:
Cost Breakdown¶
In [9]:
Copied!
total_costs = flow_system.solution['costs'].item()
# Calculate cost components
solar_invest = solar_size * SOLAR_COST_WEEKLY
tank_invest = tank_size * TANK_COST_WEEKLY
gas_costs = total_costs - solar_invest - tank_invest
print(
f'Weekly costs: Solar {solar_invest:.1f}€ ({solar_invest / total_costs * 100:.0f}%) + Tank {tank_invest:.1f}€ ({tank_invest / total_costs * 100:.0f}%) + Gas {gas_costs:.1f}€ ({gas_costs / total_costs * 100:.0f}%) = {total_costs:.1f}€'
)
total_costs = flow_system.solution['costs'].item() # Calculate cost components solar_invest = solar_size * SOLAR_COST_WEEKLY tank_invest = tank_size * TANK_COST_WEEKLY gas_costs = total_costs - solar_invest - tank_invest print( f'Weekly costs: Solar {solar_invest:.1f}€ ({solar_invest / total_costs * 100:.0f}%) + Tank {tank_invest:.1f}€ ({tank_invest / total_costs * 100:.0f}%) + Gas {gas_costs:.1f}€ ({gas_costs / total_costs * 100:.0f}%) = {total_costs:.1f}€' )
Weekly costs: Solar 176.3€ (23%) + Tank 21.6€ (3%) + Gas 585.1€ (75%) = 783.0€
System Operation¶
In [10]:
Copied!
flow_system.statistics.plot.balance('Heat')
flow_system.statistics.plot.balance('Heat')
Out[10]:
In [11]:
Copied!
flow_system.statistics.plot.heatmap('SolarCollectors(Heat)')
flow_system.statistics.plot.heatmap('SolarCollectors(Heat)')
Out[11]:
In [12]:
Copied!
flow_system.statistics.plot.balance('BufferTank')
flow_system.statistics.plot.balance('BufferTank')
Out[12]:
Compare: What if No Solar?¶
Let's see how much the solar system saves:
In [13]:
Copied!
# Gas-only scenario
total_demand = pool_demand.sum()
gas_only_cost = total_demand / 0.92 * GAS_PRICE # All heat from gas boiler
savings = gas_only_cost - total_costs
print(
f'Solar saves {savings:.1f}€/week ({savings / gas_only_cost * 100:.0f}%) vs gas-only ({gas_only_cost:.1f}€) → {savings * 52:.0f}€/year'
)
# Gas-only scenario total_demand = pool_demand.sum() gas_only_cost = total_demand / 0.92 * GAS_PRICE # All heat from gas boiler savings = gas_only_cost - total_costs print( f'Solar saves {savings:.1f}€/week ({savings / gas_only_cost * 100:.0f}%) vs gas-only ({gas_only_cost:.1f}€) → {savings * 52:.0f}€/year' )
Solar saves 1682.2€/week (68%) vs gas-only (2465.2€) → 87476€/year
Energy Flow Sankey¶
A Sankey diagram visualizes the total energy flows through the system:
In [14]:
Copied!
flow_system.statistics.plot.sankey.flows()
flow_system.statistics.plot.sankey.flows()
Out[14]:
Key Concepts¶
InvestParameters Options¶
fx.InvestParameters(
minimum_size=0, # Lower bound (can be 0 for optional)
maximum_size=500, # Upper bound
fixed_size=100, # Or: fixed size (binary decision)
mandatory=True, # Force investment to happen
effects_of_investment={'costs': 1000}, # Fixed cost if invested
effects_of_investment_per_size={'costs': 25}, # Cost per unit size
)
Where to Use InvestParameters¶
- Flow.size: Optimize converter/source/sink capacity
- Storage.capacity_in_flow_hours: Optimize storage capacity
Summary¶
You learned how to:
- Define investment decisions with
InvestParameters - Set size bounds (minimum/maximum)
- Add investment costs (per-size and fixed)
- Access optimal sizes via
statistics.sizes - Visualize sizes with
statistics.plot.sizes()
Next Steps¶
- 04-operational-constraints: Add startup costs and minimum run times
- 05-multi-carrier-system: Model combined heat and power