This is a rough guide to get MultilayerPy working on your machine. It will reproduce the output from the KM-SUB description paper Fig. 2(a) (https://doi.org/10.5194/acp-10-3673-2010).
First of all we need to import all of the packages that we need. This also imports the build, simulate and optimize modules that are the basis of the package.
# importing the necessaries
import numpy as np
import multilayerpy
import multilayerpy.build as build
import multilayerpy.simulate as simulate
import multilayerpy.optimize as optimize
# useful to know what version of multilayerpy we are using
print(multilayerpy.__version__)
The first thing to do is define the reaction scheme. In this case, oleic acid + ozone --> products. There are 3 components.
To define the reaction scheme, a reactant_tuple_list and product_tuple_list is defined. These two objects are lists of tuples (tuple: (a,b); list of tuples:[(a,b), (c,d)]). Each member of the list (tuple) represents reactants/product(s) of that reaction. Both tuple lists need to be in the same order as the reaction scheme; i.e. reaction 2 needs to be after reaction 1 etc.
In this simple case, there is only one reaction with 2 reactants and 1 product. We need to assign a number to each of these. Here: oleic acid = 1, ozone = 2, products = 3.
It is also possible not to include any reactions (see commented-out line of code in the cell below). For example, a user may want to include water uptake to their film or particle. Water may not actually take part in any chemistry.
Now we can build up the reaction scheme using the ReactionScheme object in MultilayerPy:
# import the ModelType class
from multilayerpy.build import ModelType
# import the ReactionScheme class
from multilayerpy.build import ReactionScheme
# define the model type (KM-SUB in this case) and geometry (spherical or film)
mod_type = ModelType('km-sub','spherical')
# build the reaction tuple list, in this case only 1 tuple in the list (for 1 reaction)
# component 1 (oleic acid) reacts with component 2 (ozone)
reaction_tuple_list = [(1,2)]
# build the product tuple list, only component 3 (products) is a product
# a tuple with a single value inside is defined (value,)
product_tuple_list = [(3,)]
# now construct the reaction scheme
# we can give it a name and define the nuber of components as below
reaction_scheme = ReactionScheme(mod_type,name='Oleic acid ozonolysis',
reactants=reaction_tuple_list,
products=product_tuple_list)
# the commented-out line below defines a reaction scheme with no reactions happening. We still need to tell MultilayerPy this.
# reaction_scheme = ReactionScheme(mod_type,name='no reaction scheme')
# let's print out a representation of the reaction scheme
reaction_scheme.display()
Now we need to make the model components. This is done using the ModelComponent object in MultilayerPy, which needs to be supplied with the component number and reaction scheme. Optionally, we can give it a name.
For a KM-SUB model, volatile components (such as ozone here) need to be declared in the gas phase by setting gas=True when instantiating the ModelComponent object.
# import ModelComponent class
from multilayerpy.build import ModelComponent
# making model components
# oleic acid
OA = ModelComponent(1,reaction_scheme,name='Oleic acid')
# ozone, declare that it is in the gas phase
O3 = ModelComponent(2,reaction_scheme,gas=True,name='Ozone')
# products
prod = ModelComponent(3,reaction_scheme, name='Reaction products')
# collect into a dictionary
model_components_dict = {'1':OA,
'2':O3,
'3':prod}
This simple example does not consider diffusion as a function of particle composition. MultilayerPy does, however, have the capacity to account for this. We still need to declare that there is no diffusion evolution with particle composition. See the relevant jupyter notebook tutorial for an explanation of how to include composition-dependent diffusion.
Here, we will supply a simple None value, meaning there is no diffusion evolution. This is then supplied to the DiffusionRegime object, which stores the code that defines the diffusion of each component.
# import DiffusionRegime class
from multilayerpy.build import DiffusionRegime
# making the diffusion dictionary
diff_dict = None
# make diffusion regime
diff_regime = DiffusionRegime(mod_type,model_components_dict,diff_dict=diff_dict)
# call it to build diffusion code ready for the builder
diff_regime()
Now we can construct the model using ModelBuilder in MultilayerPy. This requires the reaction_scheme, model_components_dict and diff_regime defined earlier.
# import ModelBuilder class
from multilayerpy.build import ModelBuilder
# create the model object
model = ModelBuilder(reaction_scheme,model_components_dict,diff_regime)
# build the model. Will save a file, don't include the date in the model filename
model.build(date=False)
# print out the parameters required for the model to run (useful for constructing the parameter dictionary later)
print(model.req_params)
It is possible to customise the model code and load it into a Simulate object. Call the Simulate.set_model() method, which will take the python code filename as an argument.
The code below is commented-out and is intended to be used as a template for the user to supply their own customised model code.
# importing an edited or customised model to a Simulate object
#model_code_filename = 'my_custom_kmsub_model.py'
#sim.set_model(model_code_filename)
Models are run using the Simulate object in MultilayerPy.
The required parameters need to be supplied as a dictionary.
A description of each parameter is provided in the documentation as a spreadsheet and in the description paper.
We need to supply:
The more time-consuming steps are handled by useful utility functions available in the simulate module. Namely: simulate.initial_concentrations and simulate.make_layers, which take different arguments depending on whether a KM-SUB or KM-GAP model is being used (see the KM-GAP tutorial notebook for how these functions are used for KM-GAP models).
# import the Simulate class
from multilayerpy.simulate import Simulate
# import the Parameter class
from multilayerpy.build import Parameter
# make the parameter dictionary
# these are parameters from the KM-SUB model description paper (see reference at the start of this notebook)
param_dict = {'delta_3':Parameter(1e-7), # cm
'alpha_s_0_2':Parameter(4.2e-4),
'delta_2':Parameter(0.4e-7), # cm
'Db_2':Parameter(1e-5), # cm2 s-1
'delta_1':Parameter(0.8e-7), # cm
'Db_1':Parameter(1e-10), # cm2 s-1
'Db_3':Parameter(1e-10), # cm2 s-1
'k_1_2':Parameter(1.7e-15), # cm3 s-1
'H_2':Parameter(4.8e-4), # mol cm-3 atm-1
'Xgs_2': Parameter(7.0e13), # cm-3
'Td_2': Parameter(1e-2), # s
'w_2':Parameter(3.6e4), # cm s-1
'T':Parameter(298.0), # K
'k_1_2_surf':Parameter(6.0e-12)} # cm2 s-1
# make the simulate object with the model and parameter dictionary
sim = Simulate(model,param_dict)
# define required parameters
n_layers = 10
rp = 0.2e-4 # radius in cm
time_span = [0,40] # in s (times between which to run the model)
n_time = 999 # number of timepoints to save to output
#spherical V and A
# use simulate.make_layers function
V, A, layer_thick = simulate.make_layers(mod_type,n_layers,rp)
# initial conc. of everything
# bulk concentrations are in cm-3, surface concentrations are in cm-2
bulk_conc_dict = {'1':1.21e21,'2':0,'3':0} # key=model component number, value=bulk conc
surf_conc_dict = {'1':9.68e13,'2':0,'3':0} # key=model component number, value=surf conc
y0 = simulate.initial_concentrations(mod_type,bulk_conc_dict,surf_conc_dict,n_layers)
# now run the model
output = sim.run(n_layers,rp,time_span,n_time,V,A,layer_thick,Y0=y0)
%matplotlib inline
# plot the model
fig = sim.plot()
# uncomment the line below to see what params were used to run the model simulation
#sim.run_params
Now we have a model which has been run. The Simulate object has stored the time resolved surface and bulk concentrations for each model layer and component. These are easily accessible through Simulate.surf_concs and Simulate.bulk_concs. These are dictionaries, with keys corresponding to the component number.
Let's access the bulk concentration of oleic acid and plot it as a heatmap. Here is the long way of doing it, allowing for customization:
# get the bulk concentration array for oleic acid (component number 1)
OA_bulk_conc_arr = sim.bulk_concs['1']
# print the shape of the array (n_time,n_layers)
print('shape before transposition',OA_bulk_conc_arr.shape)
# I want the layers to be the rows, time as columns
OA_bulk_conc_arr = OA_bulk_conc_arr.T # transpose
print('shape after transposition',OA_bulk_conc_arr.shape)
# Now let's plot the heatmap
# import pyplot
import matplotlib.pyplot as plt
plt.figure()
plt.title('OA bulk conc')
plt.pcolormesh(OA_bulk_conc_arr)
plt.xlabel('Time points')
plt.ylabel('Layer number')
# invert y-axis so that layer 0 is at the top of the plot
plt.gca().invert_yaxis()
plt.colorbar(label='Conc. / cm$^{-3}$')
plt.show()
The Simulate object has a plot_bulk_concs() function which will return heatmap plots of the bulk concentration for each model component during the model run and it offers the option to save the plots. plot_bulk_concs() returns a list of figure objects which can then be used to customise each plot.
It is also possible just to plot one model component.
# plotting all model component concentration heatmaps
# and capturing the output list of figures
figures = sim.plot_bulk_concs()
# plotting a single model component (component 1) heatmap
# and capturing the figure (commented out below)
# fig = sim.plot_bulk_concs(comp_number=1)
# plotting and saving (commented out below)
# fig = sim.plot_bulk_concs(comp_number=1,save=True)
Now that we have a working model, it is likely that we would want to fit the model to some real world data. This involves creating an Optimizer object which will take a Simulate object as an input.
scipy.optimize.minimise method).scipy.optimize.differential_evolution)There is a noisy_data.txt file which accompanies this notebook. This is noisy data generated from the model output we plotted earlier. In this way we know the "true" value of the parameters we are tring to optimize.
Note: if your experimental data are already normallised, set normalised=True when instantiating the Data object (i.e. sim.data = Data(your_data, normalised=True) - a commented out line in the cell below shows how this would be done for our example fake data).
Let's make the model again and say we didn't have a good idea of the surface accommodation coefficient (alpha_s_0_2) for ozone (component 2):
# import the optimize module and Optimizer object
import multilayerpy.optimize
from multilayerpy.optimize import Optimizer
# I'll adjust the sim.parameters dictionary to change alpha_s0 to something "wrong"
# note that 'alpha_s0_2' is set to vary with some bounds
param_dict = {'delta_3':Parameter(1e-7), # cm
'alpha_s_0_2':Parameter(0.003,vary=True,bounds=(1e-4,1.0)),
'delta_2':Parameter(0.4e-7), # cm
'Db_2':Parameter(1e-5), # cm2 s-1
'delta_1':Parameter(0.8e-7), # cm
'Db_1':Parameter(1e-10), # cm2 s-1
'Db_3':Parameter(1e-10), # cm2 s-1
'k_1_2':Parameter(1.7e-15), # cm3 s-1
'H_2':Parameter(4.8e-4), # mol cm-3 atm-1
'Xgs_2': Parameter(7.0e13), # cm-3
'Td_2': Parameter(1e-2), # s
'w_2':Parameter(3.6e4), # cm s-1
'T':Parameter(298.0), # K
'k_1_2_surf':Parameter(6e-12)} # cm2 s-1
# for now, a new simulate object will be created from the original model and the new param_dict
sim = Simulate(model,param_dict)
# load in the fake noisy data - columns are (time, y, y_error)
fake_data = np.genfromtxt('noisy_data.txt')
# set the Simulate.data attribute to be the fake data, using the Data object
from multilayerpy.simulate import Data
sim.data = Data(fake_data)
# below is how we would tell multilayerpy that the experimental data are already normalised (commented out)
# sim.data = Data(fake_data, normalised=True)
# run the model and plot the initial output
output = sim.run(n_layers,rp,time_span,n_time,V,A,layer_thick,Y0=y0)
fig = sim.plot()
# the oleic acid decay is way too fast
# create an optimizer object which will vary alpha_s0_2. I've called it "fitter" here
from multilayerpy.optimize import Optimizer
fitter = Optimizer(sim)
# fit the model + experiment, default method is simplex (least-squares)
# the fitter will default to fitting the output from component 1 (oleic acid) to the data
#(see the user manual for how to change this)
# this will take a few moments...
res = fitter.fit()
# uncomment below to fit using the differential_evolution alorithm (global optimisation)
#res = fitter.fit(method='differential_evolution')
# Plot the optimised model
fig = sim.plot()
# Now let's save the model parameters along with optimised parameters to a .csv file
sim.save_params_csv(filename='crash_course_optimised_params.csv')
# we can also access the raw x-y data from the model output for each component
xy_data = sim.xy_data_total_number()
# the data are in component number order, first column is time (in seconds)
print('ALL XY DATA')
print(xy_data)
# to output just the oleic acid data (component 1) we can use the "components" argument
# get first two columns
oa_model_decay = sim.xy_data_total_number(components=1)
print('\nOLEIC ACID XY DATA')
print(oa_model_decay)
# save to a .txt file
np.savetxt('oleic_acid_crash_course_model_output.txt', oa_model_decay)
As you can see, the modelled oleic acid decay now fits very well to the experiment. Moreover, the fitted value of alpha_s0_2 was optimized as ~0.0004 which is very close to the original value (4.2e-4) which was used to generate the fake data!
Any number of the parameters in the parameter dictionary can be varied. Just set vary = True and bounds = (lower_bound,upper_bound) for the Parameter object you would like to optimise (see above).
This was a quick walkthrough to get you started with MultilayerPy and KM-SUB. In just a few lines of code a model was created, run and optimised.
A few points:
ModelBuilder.filename attribute of the model you created. In this example case model.filename will print the model filename (model is a ModelBuilder instance). For more information about the other features of MultilayerPy and the objects within it, consult the docs folder and the other tutorials which accompany the source code.