# This file was automatically generated by build-ipynb_FILES.sh from
# the PhaseTransitions-ADP.ipynb file. All changes to this file will be lost

# We studied the effect of changing the composition of the gas phase, namely
# partial pressures for $O_2$ and $CO$, in the $CO_2$ Turnover frequency (TOF),
# in some of the pyZacros tutorials for the Ziff-Gulari-Barshad model (see
# especially **Phase Transitions in the ZGB model** and
# **Ziff-Gulari-Barshad model: Steady State Conditions**). We used an evenly
# distributed grid on the $CO$ gas phase molar fraction in those tutorials.
# From the results, it is possible to see that, typically, the lower the $CO_2$
# production, the more difficult it is to achieve a steady state (except when
# $CO_2$ production is zero, likely because the surface got poisoned, which
# makes the calculation finish quickly). This means that most of the
# computational time is spent on points that, from a catalytic standpoint,
# are not as interesting as points with higher $CO_2$ production. Thus, to
# reduce the overall computational cost, it would be ideal to generate more
# points in the more interesting areas automatically. This is the main goal
# of the **Adaptive Design Procedure (ADP)**.
# 
# The ADP was created to generate training data for Machine Learning (ML) algorithms,
# with a particular emphasis on approximating computationally-intensive
# first-principles kinetic models in catalysis. The procedure is based on
# function topology and behavior, with the discrete gradient and relative
# importance of the independent variables calculated. See more details in the
# original publication [Chem. Eng. J 400, (2020), 125469](https://doi.org/10.1016/j.cej.2020.125469).
# The authors demonstrate that the procedure could design a dataset (achieving
# the same accuracy) using only between 60 and 80% fewer data points as are
# needed in an evenly distributed grid. Furthermore, the ADP generates a
# **Surrogate Model** of the data based on ML techniques,
# allowing interpolation of points not included in the original data set,
# which is critical for multiscale simulations of complex chemical reactors.
# 
# In this tutorial, we will likewise examine the effects of altering the gas
# phase's composition in the $CO_2$ Turnover frequency (TOF) in the ZGB model,
# but we will do so while utilizing the ADP to both suggest the values of the
# $CO$ molar fractions to evaluate and generate a surrogate model for this solution.
# In practice, the surrogate model is a [PKL file](https://docs.python.org/3/library/pickle.html)
# that contains all of the parameters of the ML model, allowing it to be regenerated
# and used subsequently. The main goal of this tutorial is to obtain this file.

# First of all, we must install the package **adaptiveDesignProcedure**. You
# can either follow the procedure described in its GitHub repository
# [https://github.com/mbracconi/adaptiveDesignProcedure](https://github.com/mbracconi/adaptiveDesignProcedure),
# or if you are using AMS, you can do it as follows by typing in a terminal:
# ```
# $ amspackages install adaptivedesignprocedure
# ```

# Now, let's start with the script. Foremost, we import all packages we need:

import multiprocessing
import numpy

import scm.plams
import scm.pyzacros as pz
import scm.pyzacros.models

import adaptiveDesignProcedure as adp
import warnings; warnings.simplefilter('ignore', UserWarning)


# The ``import warning`` line is just needed to get clean output messages further down.

# The ADP method needs a function that generates the data for the model;
# we named it ``get_rate()``. This function accepts a single argument,
# a 2d-array containing the conditions to be computed with the shape
# ``(number of conditions, number of input variables)``, and returns a
# 2d-array containing the calculated values with the shape
# ``(number of conditions, number of output variables)``. The number of
# conditions will be determined on the fly by the ADP. On the contrary,
# we must decide on the number of input and output variables. In our
# example, we have ``one input variable``, the molar fraction of CO, and
# ``three output variables``, the average coverage for $O*$ and $CO*$
# and the $CO_2$ TOF.
# 
# This ``get_rate()`` function performs a ``ZacrosParametersScanJob``
# calculation. To follow the details, please refer to the example
# **Phase Transitions in the ZGB model**. In a nutshell, it configures
# the ``ZacrosJob`` calculation with the ZGB predefined model. Then it
# configures and runs the ``ZacrosParametersScanJob``, using the
# ``ZacrosJob`` defined before as the reference and setting the $CO$
# molar fraction equal to the conditions established by the ADP. Finally,
# it retrieves the results for each condition by calling the
# ``turnover frequency()`` and ``average coverage()`` functions and
# storing them in the output array in the correct order.

def get_rate( conditions ):
    
    print("")
    print("  Requesting:")
    for cond in conditions:
        print("    x_CO = ", cond[0])
    print("")
    
    #---------------------------------------
    # Zacros calculation
    #---------------------------------------
    zgb = pz.models.ZiffGulariBarshad()

    z_sett = pz.Settings()
    z_sett.random_seed = 953129
    z_sett.temperature = 500.0
    z_sett.pressure = 1.0
    z_sett.species_numbers = ('time', 0.1)
    z_sett.max_time = 10.0

    z_job = pz.ZacrosJob( settings=z_sett, lattice=zgb.lattice,
                          mechanism=zgb.mechanism,
                          cluster_expansion=zgb.cluster_expansion )

    #---------------------------------------
    # Parameters scan calculation
    #---------------------------------------
    ps_params = pz.ZacrosParametersScanJob.Parameters()
    ps_params.add( 'x_CO', 'molar_fraction.CO', [ cond[0] for cond in conditions ] )
    ps_params.add( 'x_O2', 'molar_fraction.O2', lambda params: 1.0-params['x_CO'] )

    ps_job = pz.ZacrosParametersScanJob( reference=z_job, parameters=ps_params )

    #---------------------------------------
    # Running the calculations
    #---------------------------------------
    results = ps_job.run()
    
    if not results.job.ok():
        print('Something went wrong!')

    #---------------------------------------
    # Collecting the results
    #---------------------------------------
    data = numpy.nan*numpy.empty((len(conditions),3))
    if( results.job.ok() ):
        results_dict = results.turnover_frequency()
        results_dict = results.average_coverage( last=20, update=results_dict )

        for i in range(len(results_dict)):
            data[i,0] = results_dict[i]['average_coverage']['O*']
            data[i,1] = results_dict[i]['average_coverage']['CO*']
            data[i,2] = results_dict[i]['turnover_frequency']['CO2']

    return data


# Now, let's look at the script's main part.

# As usual, we initialize the pyZacros environment:

scm.pyzacros.init()


# We'll use the ``plams.JobRunner`` class, which easily allows us to run as many parallel
# instances as we request. In this case, we choose to use the maximum number of
# simultaneous processes (``maxjobs``) equal to the number of processors in the
# machine. Additionally, by setting ``nproc =  1`` we establish that only one
# processor will be used for each zacros instance. 

maxjobs = multiprocessing.cpu_count()
scm.plams.config.default_jobrunner = scm.plams.JobRunner(parallel=True, maxjobs=maxjobs)
scm.plams.config.job.runscript.nproc = 1
print('Running up to {} jobs in parallel simultaneously'.format(maxjobs))


# Firstly, we must define the input and output variables. As previously stated, for
# the input variables, we only have the molar fraction of CO (``x_CO``). In terms of
# ADP, we must provide a name as well as a range to cover during the execution,
# ranging from ``min`` to ``max`` and beginning with ``num`` evenly spaced samples.
# Regarding the output variables, we have three: the average coverage for $O*$ and
# $CO*$, as well as the $CO_2$ TOF. For them, in ADP, we only need to provide their
# names (``ac_O``, ``ac_CO``, ``TOF_CO2``). Notice these names are arbitrary, and
# they do not have any effect on the final results. However, the number of input
# and output variables should be in correspondence with the array sizes used in the
# ``get_rate()`` function.

input_var = ( { 'name':'x_CO', 'min':0.2, 'max':0.8, 'num':5 }, )

output_var = ( {'name':'ac_O'},
               {'name':'ac_CO'},
               {'name':'TOF_CO2'}, )


# Then, we create an ``adaptativeDesignProcedure`` object by calling its constructor,
# which needs the input and output variables, the function to calculate the rates
# ``get_rates``. Additionally, for convenience, we set the output directory as a
# subdirectory of the pyZacros working directory and set the random seed
# (``randomState``) to get reproducible results. The last two parameters are optional.
# It is also possible to provide several parameters to control the algorithm using
# the keyword ``algorithmParams''. But we will get back to that later.

adpML = adp.adaptiveDesignProcedure( input_var, output_var, get_rate,
                                     outputDir=scm.pyzacros.workdir()+'/adp.results',
                                     randomState=10 )


# Now, we begin the calculation by invoking the method ``createTrainingDataAndML()``,
# which, as the name implies, generates the training data as well as the surrogate model
# (or ML model). The program runs several cycles, and for each cycle,
# increases the number of points and calls the "get rate()" function to evaluate them.
# When the Relative Approximation Error and the Out-Of-Bag error are less than
# ``algorithmParams['RADth']`` (default=10%) and ``algorithmParams['OOBth']`` (default=0.05),
# respectively, the calculation converges. See the original ADP documentation for
# more details.

adpML.createTrainingDataAndML()


# If the execution got up to this point, everything worked as expected. Hooray!
# 
# The results are then collected by accessing the ``trainingData`` attribute of the
# ``adpML`` object, and they are presented nicely in a table in the lines that follow.

x_CO,ac_O,ac_CO,TOF_CO2 = adpML.trainingData.T

print( "-------------------------------------------------" )
print( "%4s"%"cond", "%8s"%"x_CO", "%10s"%"ac_O", "%10s"%"ac_CO", "%12s"%"TOF_CO2" )
print( "-------------------------------------------------" )
for i in range(len(x_CO)):
    print( "%4d"%i, "%8.3f"%x_CO[i], "%10.6f"%ac_O[i], "%10.6f"%ac_CO[i], "%12.6f"%TOF_CO2[i] )


# The above results are the final aim of the calculation. However, we
# can take advantage of python libraries to visualize them. Here, we
# use matplotlib. Please check the matplotlib documentation for more
# details at [matplotlib](https://matplotlib.org/). The following lines
# of code allow visualizing the effect of changing the $CO$ molar
# fraction on the average coverage of $O*$ and $CO*$ and the production
# rate of $CO_2$. In the Figure, we show the training data generated by
# the Zacros model as points and the Surrogate Model's prediction as lines.

import matplotlib.pyplot as plt

fig = plt.figure()

x_CO_model = numpy.linspace(0.2,0.8,201)
ac_O_model,ac_CO_model,TOF_CO2_model = adpML.predict( x_CO_model ).T

ax = plt.axes()
ax.set_xlabel('Molar Fraction CO', fontsize=14)

ax.set_ylabel("Coverage Fraction (%)", color="blue", fontsize=14)
ax.plot(x_CO_model, ac_O_model, color="blue", linestyle="-.", lw=2, zorder=1)
ax.plot(x_CO, ac_O, marker='$\u25EF$', color='blue', markersize=4, lw=0, zorder=1)
ax.plot(x_CO_model, ac_CO_model, color="blue", linestyle="-", lw=2, zorder=2)
ax.plot(x_CO, ac_CO, marker='$\u25EF$', color='blue', markersize=4, lw=0, zorder=1)
plt.text(0.3, 0.9, 'O', fontsize=18, color="blue")
plt.text(0.7, 0.9, 'CO', fontsize=18, color="blue")

ax2 = ax.twinx()
ax2.set_ylabel("TOF (mol/s/site)",color="red", fontsize=14)
ax2.plot(x_CO_model, TOF_CO2_model, color='red', linestyle='-', lw=2, zorder=0)
ax2.plot(x_CO, TOF_CO2, marker='$\u25EF$', color='red', markersize=4, lw=0, zorder=1)
plt.text(0.37, 1.5, 'CO$_2$', fontsize=18, color="red")

plt.show()


# We also found three regions involving the two phase transitions, as shown
# in the Figure, which are similar to the ones obtained in the example
# **Phase Transitions in the ZGB model**. However, as you can see, the
# points are not equidistant here. The ADP generated more points for
# $x_\text{CO}$ between 0.35 and 0.55, which is likely to represent the
# small oscillations that occur there accurately. It is worth noting that
# the Surrogate Model has some difficulty reproducing the narrow peak at a
# maximum $CO_2$ TOF. Certainly, the model could perform better. Unfortunately,
# the only way to do so is by increasing the number of points in the training
# set by tuning the ADP parameters' (``algorithmParams``). However, now that
# we have the surrogate model, we can quickly obtain the average coverage for
# $O*$ and $CO*$ and the $CO_2$ TOF for any $CO$ molar fraction. 

# Now, we can close the pyZacros environment:

scm.pyzacros.finish()


# As a final note, you don't have to recalculate any training points if you load the
# surrogate model directly from the disk. As a result, you can apply it to any other
# situation, such as e.g., a simulation of reactive computational fluid dynamics (rCFD).
# The following code creates the image above exactly, but it starts by reading the
# surrogate model from the directory ``plams_workdir/adp.results/``. It can be used
# in a separate script.

import numpy as np
import adaptiveDesignProcedure as adp
import matplotlib.pyplot as plt

path = "plams_workdir/adp.results/"

x_CO_model = np.linspace(0.2,0.8,201)
ac_O_model,ac_CO_model,TOF_CO2_model = adp.predict( x_CO_model, path ).T

ax = plt.axes()
ax.set_xlabel('Molar Fraction CO', fontsize=14)

ax.set_ylabel("Coverage Fraction (%)", color="blue", fontsize=14)
ax.plot(x_CO_model, ac_O_model, color="blue", linestyle="-.", lw=2, zorder=1)
ax.plot(x_CO_model, ac_CO_model, color="blue", linestyle="-", lw=2, zorder=2)
plt.text(0.3, 0.9, 'O', fontsize=18, color="blue")
plt.text(0.7, 0.9, 'CO', fontsize=18, color="blue")

ax2 = ax.twinx()
ax2.set_ylabel("TOF (mol/s/site)",color="red", fontsize=14)
ax2.plot(x_CO_model, TOF_CO2_model, color='red', linestyle='-', lw=2, zorder=0)
plt.text(0.37, 1.5, 'CO$_2$', fontsize=18, color="red")

plt.show()


# By default, the Surrogate Model's file is ``ml_ExtraTrees_forCFD.pkl``. The option
# ``forestFile`` in the ADP constructor allows you to alter the prefix ``ml_ExtraTrees``.
# In the ``adp.predict()`` method, you can provide the complete path to this file, but if
# a directory is supplied instead, it will try to discover the proper file inside,
# as shown in the lines of code above. 
