Advanced features

  • File name: advanced_features.ipynb

  • Last edited: 2020-06-25

  • Created by: Stefan Bruche (TU Berlin)

import copy
import pandas as pd
import aristopy as ar

# Get data from csv-file
data = pd.read_csv('testdata.csv', sep=';', decimal='.', index_col=[0])

# USER-INPUT: "hpts" (hours per time step)
hpts = 4

# Aggregate data with "hours_per_time_step" for simplification of this notebook
c_elec = ar.utils.aggregate_time_series(data['c_elec [EUR/MWh]'], hpts, 'mean')
q_demand = ar.utils.aggregate_time_series(data['Q_demand [MWh]'], hpts, 'sum')

# Create energy system instance
es = ar.EnergySystem(
    number_of_time_steps=8760//hpts, hours_per_time_step=hpts,
    interest_rate=0.05, economic_lifetime=20, logging=ar.Logger(
        default_log_handler='file', default_log_level='INFO',
        write_screen_output_to_logfile=True))

# Add a gas source, a heat and electricity sink and a heat bus for collecting
gas_source = ar.Source(
    ensys=es, name='gas_source', commodity_cost=20, outlet=ar.Flow('Fuel'))

heat_sink = ar.Sink(
    ensys=es, name='heat_sink', inlet=ar.Flow('Heat', link='heat_bus'),
    commodity_rate_fix=ar.Series('q_demand', q_demand))

elec_sink = ar.Sink(
    ensys=es, name='elec_sink', inlet=ar.Flow('Elec'),
    commodity_revenues=ar.Series('c_elec', c_elec))

heat_bus = ar.Bus(
    ensys=es, name='heat_bus',
    inlet=ar.Flow('Heat', var_name='Heat_inlet'),
    outlet=ar.Flow('Heat', var_name='Heat_outlet'))

gas_boiler = ar.Conversion(
    ensys=es, name='gas_boiler', basic_variable='Heat',
    inlet=ar.Flow('Fuel', 'gas_source'), outlet=ar.Flow('Heat', 'heat_bus'),
    capacity_max=250, capex_per_capacity=60e3,
    user_expressions='Heat == 0.9 * Fuel')

# Add a group of 5 gas engine units with fixed size
gas_engine = ar.Conversion(
    ensys=es, name='gas_engine', basic_variable='Elec',
    inlet=ar.Flow('Fuel', 'gas_source'),
    outlet=[ar.Flow('Heat', 'heat_bus'), ar.Flow('Elec', 'elec_sink')],
    has_existence_binary_var=True, has_operation_binary_var=True,
    additional_vars=ar.Var('Load', ub=1), scalar_params={'dt': hpts},
    capacity=20, min_load_rel=0.2,
    user_expressions=['Load <= BI_OP',
                      'Elec == (20 * Load) * dt',
                      'Heat <= (17 * Load + 2.5 * BI_OP) * dt',
                      'Fuel == (37 * Load + 7 * BI_OP) * dt'],
    capex_per_capacity=1000e3, opex_per_capacity=12.5e3, opex_operation=6,
    instances_in_group=5)

# Add a heat storage component
heat_storage = ar.Storage(
    ensys=es, name='heat_storage',
    inlet=ar.Flow('Heat', link='heat_bus', var_name='Heat_charge'),
    outlet=ar.Flow('Heat', link='heat_bus', var_name='Heat_discharge'),
    has_existence_binary_var=True,
    maximal_module_number=5, capacity_per_module=50,  # max: 5 * 50 MWh
    capex_per_capacity=12e3, opex_per_capacity=0.015*12e3,  # 12,000 €/MWh CAPEX
    self_discharge=0.01, charge_rate=1/6, discharge_rate=1/6,
    opex_operation=1e-9, use_inter_period_formulation=False)

# Use a backdoor to the pyomo model instance and add a user-defined constraint
# specifying the installed nominal thermal capacity needs to be at least 220 MW.
def minimal_installed_capacity_heat(ensys, m):
    expr = 0
    for comp in ensys.components.values():
        if comp.name == 'gas_boiler':
            expr += comp.block.CAP
        elif comp.group_name == 'gas_engine':
            expr += 19.5 * comp.block.BI_EX
    return expr >= 220
es.add_constraint(name='min_cap_heat', has_time_set=False,
                  rule=minimal_installed_capacity_heat)


# Create a deepcopy of the original (full scale) model instance for later use
original_model = copy.deepcopy(es)


# Conventional optimization:
# ~~~~~~~~~~~~~~~~~~~~~~~~~~
# Run the optimization of the original model instance
es.optimize(solver='gurobi', time_limit=300, optimization_specs='mipgap=5e-3')


# Time series clustering:
# ~~~~~~~~~~~~~~~~~~~~~~~
# Cluster time series data to 20 typical days.
es.cluster(number_of_typical_periods=20,
           number_of_time_steps_per_period=24//hpts)

# Use aggregated data to run the optimization
es.optimize(use_clustered_data=True)

# Plot results for a single period and scaled to full year with hourly resolution
plotter = ar.Plotter('results.json')
plotter.plot_operation('heat_storage', 'Heat', ylabel='Thermal energy [MWh]',
                       file_name='heat_storage', plot_single_period_with_index=7)
plotter.plot_operation('heat_bus', 'Heat', ylabel='Thermal energy [MWh]',
                       file_name='heat_bus', scale_to_hourly_resolution=True)


# Two-stage model run:
# ~~~~~~~~~~~~~~~~~~~~
# Construct (declare) a persistent model of the original (full scale) instance
original_model.declare_model(use_clustered_data=False, declare_persistent=True)

# Cluster time series data, declare the model and relax the integrality of the
# binary operation variables
es.cluster(number_of_typical_periods=20,
           number_of_time_steps_per_period=24 // hpts)
es.declare_model(use_clustered_data=True)
es.relax_integrality(include_time_dependent=True,
                     include_existence=False, include_modules=False)

# Iteratively run the model (1st stage and 2nd stage)
for icc in range(3):

    # Run optimization with clustered data
    es.optimize(use_clustered_data=True, declare_model=False,
                tee=False, results_file=f'1st_stage_results_{icc}.json')

    # Export the configuration to a DataFrame and store it in an Excel-File
    config = es.export_component_configuration(
        write_to_excel=True, file_name=f'1st_stage_config_{icc}.xlsx')

    # Import the optimal configuration and fix design variables
    original_model.import_component_configuration(config)

    # Run the already declared persistent, full scale model
    original_model.optimize(declare_model=False, tee=False,
                            results_file=f'2nd_stage_results_{icc}.json')

    # Reset variables to their original (unfixed) values
    original_model.reset_component_variables()

    # Add an integer-cut constraint for the gas engines to the aggregated model
    # => Exclude previously found optimal design from the solution space
    es.add_design_integer_cut_constraint(which_instances=['gas_engine'])

Data and model

Read the input data from a csv-file for one year in hourly resolution (8760 time steps) with pandas.

[1]:
# Import the required packages (jupyter magic only required for jupyter notebooks)
%reload_ext autoreload
%autoreload 2
%matplotlib inline

import copy
import pandas as pd
import aristopy as ar

# Get data from csv-file
data = pd.read_csv('testdata.csv', sep=';', decimal='.', index_col=[0])
[2]:
# print the first 8 rows
data.head(8)
[2]:
c_elec [EUR/MWh] Q_demand [MWh]
01.01.2018 00:00 -5.27 51.828932
01.01.2018 01:00 -29.99 51.123716
01.01.2018 02:00 -56.65 49.202127
01.01.2018 03:00 -63.14 49.060032
01.01.2018 04:00 -64.62 52.669893
01.01.2018 05:00 -67.00 59.055207
01.01.2018 06:00 -72.54 64.771925
01.01.2018 07:00 -76.01 66.424269

Aggregate the data for simplification purposes in this notebook by summing/averaging over 4 hours. There is a built-in function in the file utils.py to assist in completing this task.

[3]:
# USER-INPUT: "hpts" (hours per time step)
hpts = 4

# Aggregate data with "hours_per_time_step" for simplification of this notebook
c_elec = ar.utils.aggregate_time_series(data['c_elec [EUR/MWh]'], hpts, 'mean')
q_demand = ar.utils.aggregate_time_series(data['Q_demand [MWh]'], hpts, 'sum')

# Show aggregated data on screen
c_elec, q_demand
[3]:
(array([-38.7625, -70.0425, -64.1025, ...,  61.3   ,  64.1375,  43.5775]),
 array([201.21480811, 242.92129394, 306.69530448, ..., 432.8322987 ,
        463.4687946 , 381.27112154]))

As in the first example, first, an energy system instance must be created as the main model container. This time an additional logger is added to store information about the modeling and solving process in a log-file. Moreover, a gas source and electricity and heat sinks are added.

[ ]:
# Create energy system instance
es = ar.EnergySystem(
    number_of_time_steps=8760//hpts, hours_per_time_step=hpts,
    interest_rate=0.05, economic_lifetime=20, logging=ar.Logger(
        default_log_handler='file', default_log_level='INFO',
        write_screen_output_to_logfile=True))

# Add a gas source, a heat and electricity sink and a heat bus for collecting
gas_source = ar.Source(
    ensys=es, name='gas_source', commodity_cost=20, outlet=ar.Flow('Fuel'))

heat_sink = ar.Sink(
    ensys=es, name='heat_sink', inlet=ar.Flow('Heat', link='heat_bus'),
    commodity_rate_fix=ar.Series('q_demand', q_demand))

elec_sink = ar.Sink(
    ensys=es, name='elec_sink', inlet=ar.Flow('Elec'),
    commodity_revenues=ar.Series('c_elec', c_elec))

The bus component collects all heat flows at the inlet and transports them (with or without losses) to the outlet.

Note:

The same commodity is present at the inlet and the outlet. Therefore, different names for the created variables must be specified in the corresponding flows.

[5]:
heat_bus = ar.Bus(
    ensys=es, name='heat_bus',
    inlet=ar.Flow('Heat', var_name='Heat_inlet'),
    outlet=ar.Flow('Heat', var_name='Heat_outlet'))

gas_boiler = ar.Conversion(
    ensys=es, name='gas_boiler', basic_variable='Heat',
    inlet=ar.Flow('Fuel', 'gas_source'), outlet=ar.Flow('Heat', 'heat_bus'),
    capacity_max=250, capex_per_capacity=60e3,
    user_expressions='Heat == 0.9 * Fuel')

Next, we add five gas engine instances with identical specifications that are simultaneously created and arranged in a group (see “instances_in_group”). The five components can work independently, but have an order for their binary existence and operation variables (see API of the Conversion class).

[6]:
# Add maximal 5 gas engine units with fixed size
gas_engine = ar.Conversion(
    ensys=es, name='gas_engine', basic_variable='Elec',
    inlet=ar.Flow('Fuel', 'gas_source'),
    outlet=[ar.Flow('Heat', 'heat_bus'), ar.Flow('Elec', 'elec_sink')],
    has_existence_binary_var=True, has_operation_binary_var=True,
    additional_vars=ar.Var('Load', ub=1), scalar_params={'dt': hpts},
    capacity=20, min_load_rel=0.2,
    user_expressions=['Load <= BI_OP',
                      'Elec == (20 * Load) * dt',
                      'Heat <= (17 * Load + 2.5 * BI_OP) * dt',
                      'Fuel == (37 * Load + 7 * BI_OP) * dt'],
    capex_per_capacity=1000e3, opex_per_capacity=12.5e3, opex_operation=6,
    instances_in_group=5)

The performance modeling in the user expressions of the gas engines involves using binary operating variables (default name “BI_OP” is defined in file utils.py). Due to the y-axis intercept, the resulting efficiency curves show non-linear characteristics.

Note:

If required, we could also introduce time-varying parameters in the user-expressions to describe the dependency of the conversion rates on, e.g., ambient conditions.

[7]:
import matplotlib.pyplot as plt
import numpy as np

idx = np.linspace(0.2, 1, 20)
plt.plot(idx, (20*idx)/(37*idx+7)*100, label='el. efficiency')
plt.plot(idx, (17*idx+2.5)/(37*idx+7)*100, label='th. efficiency')
plt.xlabel('Load [-]'), plt.ylabel('Efficiency [%]'), plt.legend(loc=4)
plt.show()
_images/advanced_features_14_0.png

A heat storage component is also included in the design. The storage tank has a modular design and can consist of up to 5 modules of 50 MWh. It should be noted that, similar to the bus component, different variable names must be specified for the inlet and outlet flows. The small value for the argument “opex_operation” is set to prevent simultaneous loading and unloading of the storage. For all other keyword arguments, please consult the documentation of the storage class API.

[8]:
# Add a heat storage component
heat_storage = ar.Storage(
    ensys=es, name='heat_storage',
    inlet=ar.Flow('Heat', link='heat_bus', var_name='Heat_charge'),
    outlet=ar.Flow('Heat', link='heat_bus', var_name='Heat_discharge'),
    has_existence_binary_var=True,
    maximal_module_number=5, capacity_per_module=50,  # max: 5 * 50 MWh
    capex_per_capacity=12e3, opex_per_capacity=0.015*12e3,  # 12,000 €/MWh CAPEX
    self_discharge=0.01, charge_rate=1/6, discharge_rate=1/6,
    opex_operation=1e-9, use_inter_period_formulation=False)

Finally, we introduce a constraint that the total installed thermal capacity of the gas boiler and all gas engines must be at least 220 MW. To add this condition to the model, we use a back door to the main pyomo model instance, accessible via the method add_constraint of the energy system. It must be determined whether the constraint is time-dependent (or has a different set), and a function must be assigned as the constraint rule. This function needs to have at least two arguments. The first one represents the energy system instance; the second one is the pyomo model instance. For a time-dependent constraint, we would need to add two more arguments, representing period (p) and time-step (t). Furthermore, aristopy offers the possibility to add additional variables and objective function contributions to the main pyomo model instance. The functions add_variable and add_objective_function_contribution are available for this purpose. Please consult the API of the EnergySystem class for further information.

[9]:
# Use a backdoor to the pyomo model instance and add a user-defined constraint
# specifying the installed nominal thermal capacity needs to be at least 220 MW.
def minimal_installed_capacity_heat(ensys, m):
    expr = 0
    for comp in ensys.components.values():
        if comp.name == 'gas_boiler':
            expr += comp.block.CAP
        elif comp.group_name == 'gas_engine':
            expr += 19.5 * comp.block.BI_EX
    return expr >= 220
es.add_constraint(name='min_cap_heat', has_time_set=False,
                  rule=minimal_installed_capacity_heat)

Before we pass the developed model to a solver, we create a copy by using the method deepcopy of the copy-package. We will use this original model instance later.

[10]:
# Create a deepcopy of the original (full scale) model instance for later use
original_model = copy.deepcopy(es)

Conventional optimization

As in the first example, the model can simply be by calling the method optimize of the energy system instance. To control the solving process, we add a time limitation in seconds and a requested relative MIP-gap to the solver Gurobi.

[11]:
# Run the optimization of the original model instance
es.optimize(solver='gurobi', time_limit=300, optimization_specs='mipgap=1e-3',
            results_file='full_scale_results.json', tee=False)
[12]:
# show basic information about the model building and solving process
es.run_info
[12]:
{'solver_name': 'gurobi',
 'time_limit': 300,
 'optimization_specs': 'mipgap=1e-3',
 'model_build_time': 4,
 'model_solve_time': 308,
 'upper_bound': -163634109.14349702,
 'lower_bound': -163920996.8127097,
 'sense': 'maximize',
 'solver_status': 'aborted',
 'termination_condition': 'maxTimeLimit'}

The optimal design solution of the original (full scale) model instance can be accessed with the export_component_configuration method and is stated in the table below. The objective function contributions of the individual components are shown in the following figure.

[13]:
es.export_component_configuration()
[13]:
gas_source heat_sink elec_sink heat_bus gas_boiler gas_engine_1 gas_engine_2 gas_engine_3 gas_engine_4 gas_engine_5 heat_storage
BI_EX None None None None None 1 1 1 1 -0 1
BI_MODULE_EX None None None None None None None None None None {1: 1.0, 2: 1.0, 3: 1.0, 4: 1.0, 5: -0.0}
CAP None None None None 142 20 20 20 20 0 200
[14]:
ar.Plotter('full_scale_results.json').plot_objective(show_plot=True)
_images/advanced_features_26_0.png

Time series clustering

To reduce the number of variables and to speed up the calculation, we can perform the optimization for only a few typical periods. To do this, we first call the cluster method and break the time series data into elements of equal length and combine similar items into clusters. In the example below, 20 typical days are formed from the input data, and the optimization is performed with these aggregated data (“use_clustered_data” is True).

[15]:
# Cluster time series data to 20 typical days.
es.cluster(number_of_typical_periods=20,
           number_of_time_steps_per_period=24//hpts)

# Use clustered data to run the optimization
es.optimize(use_clustered_data=True, tee=False, results_file='clustered_results.json')

The required time to build and solve the model can be reduced significantly by using clustered input data. The optimal design solution found is comparable to the one of the original model.

[16]:
es.run_info
[16]:
{'solver_name': 'gurobi',
 'time_limit': None,
 'optimization_specs': '',
 'model_build_time': 0,
 'model_solve_time': 2,
 'upper_bound': -170278358.02702567,
 'lower_bound': -170278358.02702567,
 'sense': 'maximize',
 'solver_status': 'ok',
 'termination_condition': 'optimal'}
[17]:
es.export_component_configuration()
[17]:
gas_source heat_sink elec_sink heat_bus gas_boiler gas_engine_1 gas_engine_2 gas_engine_3 gas_engine_4 gas_engine_5 heat_storage
BI_EX None None None None None 1 1 1 -0 -0 1
BI_MODULE_EX None None None None None None None None None None {1: 1.0, 2: 1.0, 3: 1.0, 4: -0.0, 5: -0.0}
CAP None None None None 161.5 20 20 20 -0 -0 150

The following figures show the state of charge (SOC) of the heat storage tank and the associated loading and unloading operations for a selected typical period. Besides, the heat flows, entering and leaving the heat bus component are plotted scaled to a full year cycle.

[18]:
plotter = ar.Plotter('clustered_results.json')
plotter.plot_operation('heat_storage', 'Heat', ylabel='Thermal energy [MWh]',
                       file_name='heat_storage', plot_single_period_with_index=7,
                       show_plot=True)
plotter.plot_operation('heat_bus', 'Heat', ylabel='Thermal energy [MWh]',
                       file_name='heat_bus', scale_to_hourly_resolution=True,
                       show_plot=True)
_images/advanced_features_33_0.png
_images/advanced_features_33_1.png

Two-stage model run

The last part of this example shows the possibility of dividing the original problem into two levels to reduce computation time and enable the fast generation of several high-quality design solution candidates. The developers of aristopy have published a comparable method in this paper. In the first optimization stage, a simplified problem is considered, and a design candidate is generated. The simplification is achieved by the aggregation of time series data and the relaxation of binary operation variables. In the second step, the design candidate’s objective function value is determined for the original problem by considering the full-scale model.

Building a large optimization model in Pyomo can take a considerable time. Therefore Pyomo offers the possibility to create so-called persistent solvers (only for Gurobi and CPLEX). This solver instance is created only once, and the model is added via its persistent interface. Later, this model can be solved various times without the time-consuming building phase. The flag “declare_persistent” must be set to True during model declaration to exploit these persistent models in aristopy. Minor changes can be added to the persistent solver at a later stage. More information about the persistent solver interface of Pyomo can be found here.

[19]:
# Construct (declare) a persistent model of the original (full scale) instance
original_model.declare_model(use_clustered_data=False, declare_persistent=True)

In the following steps, the cluster function is called for the energy system model (not the original/full-scale model!), and the model is declared with aggregated data. Then the binary operating variables (ON/OFF) are relaxed so that they can take on values between zero and one. The existence variables remain binary.

[20]:
# Cluster time series data, declare the model and relax the integrality of the
# binary operation variables
es.cluster(number_of_typical_periods=20,
           number_of_time_steps_per_period=24//hpts)
es.declare_model(use_clustered_data=True)
es.relax_integrality(include_time_dependent=True,
                     include_existence=False, include_modules=False)

Subsequently, several optimization problems are iteratively solved, and the results are stored in a pandas DataFrame for further comparison. In the first step, the problem is solved with clustered data (model is not declared again!). The configuration (existence and capacity of the components) is imported into the full-scale problem. Now, this original problem is solved with a fixed design as a pure dispatch model. In the last step, an integer cut constraint is introduced in the aggregated model. Thus, the current design solution (the combination of binary variables) is excluded from the solution space, and another design solution must be generated in the next model run.

[21]:
# Create a number of integer-cuts
nbr_of_icc = 5

# Store some results in a DataFrame to compare solutions
results = pd.DataFrame(index=range(nbr_of_icc), columns=[
    'Obj. value [Mio.€]', 'Nbr. of engines [-]',
    'Capacity storage [MWh]', 'Capacity boiler [MW]',
    '1st stage time [s]', '2nd stage time [s]'])

for icc in range(nbr_of_icc):

    # Run optimization with clustered data
    es.optimize(use_clustered_data=True, declare_model=False,
                tee=False, results_file=f'1st_stage_results_{icc}.json')

    # Export the configuration to a DataFrame and store it in an Excel-File
    config = es.export_component_configuration(
        write_to_excel=True, file_name=f'1st_stage_config_{icc}.xlsx')

    # Import the optimal configuration and fix design variables
    original_model.import_component_configuration(config)

    # Run the already declared persistent, full scale model
    original_model.optimize(declare_model=False, tee=False,
                            results_file=f'2nd_stage_results_{icc}.json')

    # Reset variables to their original (unfixed) values
    original_model.reset_component_variables()

    # Add an integer-cut constraint for the gas engines to the aggregated model
    # => Exclude previously found optimal design from the solution space
    es.add_design_integer_cut_constraint(which_instances=['gas_engine'])


    results['Obj. value [Mio.€]'].loc[icc] = original_model.run_info['lower_bound'] / 1e6
    results['Nbr. of engines [-]'].loc[icc] = sum(comp.block.BI_EX.value
                                                  for comp in es.components.values()
                                                  if comp.group_name == 'gas_engine')
    results['Capacity storage [MWh]'].loc[icc] = config['heat_storage'].loc['CAP']
    results['Capacity boiler [MW]'].loc[icc] = config['gas_boiler'].loc['CAP']
    results['1st stage time [s]'].loc[icc] = es.run_info['model_solve_time']
    results['2nd stage time [s]'].loc[icc] = original_model.run_info['model_solve_time']

The following summary of the results shows that a single iteration can be performed much faster by splitting it into two optimization levels. The detected solutions are comparable to the globally optimal solution of the original problem (see above). In addition, further design candidates are evaluated, and a deeper understanding of the present model is gained.

[22]:
# print the results on the screen
results
[22]:
Obj. value [Mio.€] Nbr. of engines [-] Capacity storage [MWh] Capacity boiler [MW] 1st stage time [s] 2nd stage time [s]
0 -164.202 3 100 161.5 0 11
1 -166.424 2 100 181 0 4
2 -163.98 4 150 142 0 36
3 -172.912 1 0 200.5 0 1
4 -166.653 5 150 122.5 1 94