OpenFCST: The open-source Fuel Cell Simulation Toolbox
 All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Friends Macros Groups Pages
AppCathode Tutorial
The Tutorials Page
Collaboration diagram for AppCathode Tutorial:

Table of contents

Authors
The tutorial was developed by Michael Moore and Marc Secanell and later updated by Madhur Bhaiya and P. Mangal

Introduction

This tutorial describes how to develop an application using openFCST. The basic building blocks for creating a fuel cell mathematical model in openFCST are explained using AppCathode application as the template.
AppCathode class implements the polymer electrolyte fuel cell cathode mathematical model proposed in the following publication: The data files used to reproduce the results from the publication can be found in folder: /data/cathode//Secanell_EA07_Numerical_Optimization_PEMFC_Cathode_Electrodes. There are several subfolders for analysis, parameteric studies and design cases.

The mathematical model implemented is steady-state, isobaric and isothermal. Oxygen transport is assumed to be diffusion dominated and therefore is represented using Ficks Law. Electronic and protonic transport are governed by Ohms Law. The electrochemical reactions are modelled using Tafel kinetics. Gas diffusion layer and catalyst layer are considered isotropic.

The computational domain is shown in red in the figure below. Due to symmetry of the cell in the vertical direction, the computational domain is reduced to the smaller area encompassed by the red lines by imposing symmetry boundary conditions on the top and bottom boundaries.

tutorial_pic_cell.png



The governing equations of the model are written as follows:

\[ R_1(\vec{u}) = \nabla \cdot (c_{total}D^{eff}_{O_2} \nabla x_{O_2} ) = \frac{1}{nF}\nabla\cdot i \]

\[ R_2(\vec{u}) = \nabla \cdot (\sigma^{eff}_{m}\nabla\phi_m) = \nabla \cdot i \]

\[ R_3(\vec{u}) = \nabla \cdot (\sigma^{eff}_{s}\nabla\phi_s) = -\nabla \cdot i \]

where, for the case of a macro-homogeneous catalyst layer,

\[ \nabla \cdot i = A_v i^{ref}_0 \left( \frac{c_{O_2}} {c^{ref}_{O_2}} \right) \mbox{exp} \left( \frac{-\alpha F}{RT}(\phi_s - \phi_m - E_{th}) \right) \]

The solution variables are the protonic potential, \(\phi_m\), the electronic potential, \(\phi_s\) and, the oxygen molar fraction \(x_{O_2}\). Note that the source terms due to current production are only in the catalyst layer, while they are zero in the gas diffusion layer.

The boundary conditions are given by the picture below:

tutorial_pic_bc.png



Note that the membrane potential is set to zero at the CCL/membrane interface and that we control the voltage across the cell using the solid phase potential.

The govering equations above are nonlinear. Therefore, a nonlinear solver needs to be used to solve this problem. In OpenFCST, we solve the system of equations using a nonlinear Newton solver. Several nonlinear solvers are provided within the OpenFCST framework and therefore, we do not need to worry about them. You can specify the most appropriate nonlinear solver in the main_app_cathode_analysis.prm file, as follows:

set solver name = Newton3ppC # Linear | NewtonBasic | Newton3ppC | Newton3pp | NewtonLineSearch


When solving a nonlinear problem using a Newton method, an initial solution is needed. In OpenFCST we choose to solve the problem first in a very coarse mesh and then, refine the mesh using a posteriori error estimators. The simulations are intially solved on a relatively coarse grid and it is successively refined after the non-linear system is solved using adaptive refinement. This involves refining the mesh in the areas with the largest error in one of the solution variables between iterations. The routine estimate is used to implement the error estimator for each cell in the domain, for this application. An error estimator developed by Kelly et al. is used to choose the cells needing refinement. The number of refinement leves is defined in the parameter file.

AppCathode and all other applications which you shall develop would need to implemet the linearized form of the govering equations for your problem at the element level. Further routines have already been implemented to loop over the cells in the finite element domain and create the global stiffness matrix and right hand side. The local matrix and right hand side assembly will be implemented in your application in member functions cell_matrix and cell_residual . The global assembly will take place in assemble and residual functions in AppFrame::BlockMatrixApplication which is the parent of all applications in OpenFCST. Based on the above explanation, there are several "levels" within each simulation.

The highest level is the adaptive refinement loop, which will call the Newton solver to solve the non-linear application and then refine the mesh according to the Kelly Error Estimator (provided by deal.II). Again the number of refinement loops is chosen by the user.

The call to the adaptive refinement is made in the simulator builder class. This class is used to set up the simulation at a very high level. It is used to chose a number of key variables which are specified in the main parameter file: the application, solver (there are three Newton solvers with a different line search method) and the solver method (only adaptive refinement is currently implemented). The simulator builder will also define whether we wish to use Dakota to run an optimisation case or parametric study and is where the parameter file with the fuel cell properties, solver properties is selected. Note that to chose the application, solver and solver method, the simulator builder will call the simulator selector class. This class will read the main parameter file and will compare the inputs to a list. If a new application or solver is implemented, it will have to be added to this list. Finally, the call to the simulator builder class is made from the main file.

The next level is a nonlinear application solver which is implemented in the Newton solver classes in FCST. The structure on which these classes are based is contained within AppFrame through the ApplicationCopy class. This will ask the application to solve the linear system, will update the solution according to the chosen line search and repeat until the residual meets the chosen tolerance.

At the lowest level is the system of linearized equations. AppCathode and all other applications are responsible for creating this system of linearized equations and solving it. The Newton solver uses this information to solve the nonlinear problem. The linearization of the governing equations above is given by

\[ -\frac{\partial R_i(\vec{u}^{n-1})}{\partial u_j} (-\delta \vec{u}_j) = -R_i(\vec{u}^{n-1}) \]

where \( n \) is the Newton step. The equations are discretised using finite elements and solved using the Galerkin formulation. The solution is updated and the system is solved again until the desired residual is achieved. This is represented graphically below:

tutorial_pic_newton.png



The task of the AppCathode is to create the linearized system of equations above, discretise it and solve it. AppCathode inherits from AppFrame::BlockMatrixApplication. The latter implements a loop over all elements of the computational domain. Therefore, only the local (element level) assembly of the weak form of the governing equations is required. The global system is obtained using routines already implemented. A system matrix is assembled and then it is solved. The equations are stored in OpenFCST, which therefore provides the coefficients for the matrix and RHS. AppFrame provides the data structure for the application (through the BlocMatrixApplication and DoFApplication classes) and the functions to do the assembly. deal.II provides the information about the mesh, the nodes and the finite elements, as well as the solvers for the linear system.

In summary, creating and running a simulation in OpenFCST will consist of creating an application that uses various FCST classes to build a system of local (element level) linear equations that characterizes the fuel cell and solve the global system. These equations are then used by our already implemented routines to assemble the global system (for the whole mesh) and solve the nonlinear problem using a Newton solver. Finally, to increase accuracy the problem is solved in different mesh refinements.

The loops are represented graphically in the figure below:

tutorial_fcst_loops.png
The linear application contained in the application classes of FCST is represented by the green box, while the Newton solver loop is the blue box. Finally the red represents the adaptive refinement loop. Note that in the flow chart above, the optimisation package implemented in Dakota is not included as it is beyond the scope of this tutorial.

To use the FCST code to create new simulations, the user should only need to create a new linear application. The code is built so that the user should not need to know the deal.II or Appframe libraries in depth, indeed knowledge of the implementation of the Newton solver or the adaptive refinement (both of which are implemented in FCST) is not crucial. A new simulation can be created and run by simply creating a new application so with this in mind the tutorial will start with the header file of an example application, AppCathode, and will explain at a basic level all the components that are required to build the application by examining each line of code in sequence. Then the source file will be examined and provide more depth to the explanation given in the header file.

The simulations are defined using parameter files, simple text files with a .prm file extension that can be read by the FCST code (Note that some parameters can be defined using default values in the code itself, in the declare_parameters function).

In order to run the simulation, do the following: The program will run and return the appropriate results. The main and data parameter files will be explained in the following subsections.The application can be used to solve a cathode with and without an MPL by changing the parameters in the data_app_cathode_parametric.prm file.
The model can also be used to solve an agglomerate catalyst layer model. The governing equations are similar to the ones outlined above, however, the volumetric current density source, i.e. \( \nabla \cdot i \) is obtained as specified in the following article:

How to modify the parameter file to solve these additional cases will be explain in section The parameter file

A number of different libraries are involved in the simulation, first being the deal.II libraries. These libraries provide the finite elements and linear solvers required to solve the problem. Next are the AppFrame libraries that actually assemble the system of equations and form the basis of the applications that are created in FCST. Finally, the Dakota libraries are used to run optimisation studies and parametric studies.

The commented program

The include file

Here, we will step through the include file, app_cathode.h, and explain each line of code.The first step is to set the compiler flag that defines this file as the app_cathode class

#ifndef _FUELCELL__APP_CATHODE__H
#define _FUELCELL__APP_CATHODE__H
Next we include all the necessary header files, starting with the general C++ libraries.The first classes to be defined are standard C++ libraries that are used to output data to the screen.

//Include STL
#include "fstream"
#include "iostream"
#include "sstream"
Next are the libraries that form the very core of the simulation, the deal.II libraries. Then the appframe libraries, which are the basis of the applications used in the FCST code. Finally, we will look at the fuel cell specific FCST libraries.

deal.II

The first four are basic components of the FCST code that will be present in all the applications. The parameter_handler class is used to read in the prm files that contain the necessary information to encapsulate a fuel cell simulation.

// Include deal.II classes
#include "base/parameter_handler.h"
The function classes are used to create an interface that passes the initial solution to the finite element solver in deal.II. An function object is passed to a deal.II function, VectorTools::interpolate, that will call a vector_value function from FCST to return the initial solution for the simulation.
#include "base/function_lib.h"
#include "base/function.h"
The quadrature class is used for defining and using quadrature objects. The main function of these objects is to provide information about the location and weightings of each quadrature point in a cell.

#include "base/quadrature_lib.h"
The next block of classes are related to the solvers that will be used to solve the system of equations.

#include "lac/block_vector.h"
#include "lac/full_matrix.h"
#include "lac/solver_cg.h"
#include "lac/solver_gmres.h"
#include "lac/precondition.h"
#include "lac/precondition_block.h"
#include "lac/block_matrix_array.h"
#include "lac/filtered_matrix.h"
#include "lac/sparse_ilu.h"
#include "lac/sparse_direct.h"
Next are the grid classes, with one for generating grid, two for looping over cell or faces and another to handle boundary conditions.

#include "grid/grid_generator.h"
#include "grid/tria_accessor.h"
#include "grid/tria_iterator.h"
#include "grid/tria_boundary_lib.h"
The fe values class is used to store information about the finite elements that are used to discretise the equations.

#include "fe/fe_values.h"
The numerics classes are used to define convenient objects that are used to store information in a manner more suited to scientific computing. They include vector and matrix classes which are used widely in FCST. Also included is the error_estimator class, which is used for grid refinement.

#include "numerics/vectors.h"
#include "numerics/matrices.h"
#include "numerics/error_estimator.h"
Finally, the deal.II library includes boost as one of its contributing libraries. Here we use the boost shared_pointers instead of C++ pointers because they are more robust in terms of memory leaks.

#include "boost/shared_ptr.hpp"

Appframe

Next is the appframe libraries, which are used to build the system of equations. The block_matrix_application class is one of the parent classes of the applications used in FCST. All applications inherit from block_matrix_application. optimization_block_matrix_application which inherits from block_matrix_application and in turn applications inherit directly from it. It allows for the optimisation of simulations using the Dakota libaries.

// Include appframe classes
The last two classes used from appframe, are the appshop classes used to assemble the right and left hand side of our equations.

FCST

The FCST classes are those that are specific to the generation of a fuel cell simulation. The first is Solver_utils which contains a few functions that used by the solvers to check and modify the diagonal entries of the assembled matrix.

// Include FuelCell classes
#include "solver_utils.h"
Next is the geometries class which defines the dimensions of the cell, generates the grid for each layer and contains the material id and boundary ids.

#include "geometries.h"
The next three classes to be defined are the constants class, which contains universal constants such as Faradays constant or the gas constant. The operating conditions class defines variables such as the operating temperature of the cell, the pressure at the cathode or the molar fraction of hydrogen at the anode and the system management class contains all the equations, variable names used in FCST, and also used for coupling the equations. Depending on the simulations, relevant equations and variables are accessed.

Next are two classes that describe the gases used in the fuel cell and how they interact. The first contains the properties about a number of different gases, for example the viscosity of oxygen or critical temperature of nitrogen. Also described are hydrogen and water vapour. The second describes their interaction. The class will take two gases and return the diffusion coefficient according to Chapman Enskog theory for gas diffusion and Wilke Chang or Hans Bartels Interpolation for liquid diffusion.

#include "PureGas.h"
OpenFCST contains classes that implment the weak form of many partial differential equations that govern the physical processes in a fuel cell. For example, classes exist to implement the weak form of Fick's law of diffusion, Ohm's law, the Navier-Stoke equations, etc. All this classes are stored inside the #Equation namespace in FCST.
In our application, we need Fick's law, Ohm's law for that electron and protons and a source term for the electrochemical reaction. This objects are included in our application here.A fuel cell is made of many composite materials for which effective properties need to be obtained. OpenFCST contains a library of layers for fuel cells under namespace #Layers. The layers are made of different materials and are used to compute the coefficients for the governing equations implemented in the classes above. Layer classes will return information about the effective transport properties of species through each layer, e.g. oxygen diffusion, protonic conductivity, permeabilities etc. The catalyst layers also provide the interface to the kinetics class, which handle the electrochemical reactions in the cell.

Because OpenFCST code relies heavily on the deal.II libraries, the deal.II namespace is used in most of the header files. This saves from prefixing all calls to deal.II functions and member variables with deal.II.

using namespace deal.II;

The Member Function Declaration

FCST function and member variables use one of the FuelCell namespaces. There are two main namespaces, FuelCell and FuelCellShop.
FuelCellShop is the container for the data classes while FuelCell contains the applications, geometries and intial solutions. The main namespace that is used is the FuelCell::Application, the container for all the applications that have been developed for the FCST code.
namespace FuelCell
{
Application namespace is used for all applications, i.e. all the routines that implement the linear system to be solved using a Newton solver.
namespace Application
{
For any application, the first that needs to be declared is a constructor and a destructor. When creating an application, the constructor will create an object of type application data (AppFrame::ApplicationData). This is a structure that will handle the general data for the application.
AppCathode ( boost::shared_ptr<AppFrame::ApplicationData> data =
boost::shared_ptr<AppFrame::ApplicationData> () );
The class destructor will delete any pointers that have not been used by the class. *

~AppCathode();
All applications have a delcare_parameters function. This function is used to specify the data that is expected from the input file and default data to be used in case the input file does not contain any information for that variable. The data is stored in the ParameterHandler object which is an object in the nonlinear application.

virtual void declare_parameters ( ParameterHandler& param );
All applications that are used for parameteric and optimization studies with Dakota need the function set_parameters. This function is called by the optimization loop in order to set the values in the ParameterHandler to the new design parameters.
Since ParameterHandler depends on the problem we are solving, set_parameters() is set at the most inner loop of the application.

virtual void set_parameters ( const std::vector<std::string>& name_dvar,
const std::vector<double>& value_dvar,
ParameterHandler& param );
All applications have a routine initialize. This routine is used to read from ParameterHandler object the values for all the data necessary to setup the application problem to be solved. Note that declare_parameters simply specifies the values that will be read from file. In initialize, the values from the input file are actually read and stored inside the application for later use.

virtual void initialize ( ParameterHandler& param );

This routine is called by initialize and it is simply used to initialize data unique to the application.

void _initialize ( ParameterHandler& param );
Initilize also creates the computational domain. To do so it will call this routine which determines whether reading from a grid file or creating new mesh

void initialize_grid ( ParameterHandler& param );
This member function is used to create the initial solution for our problem

void init_solution ( AppFrame::FEVector& src ) ;
All application need a member function, cell_matrix, that generates the element wise finite element matrix for the system of equations that is solved. Here we loop over the quadrature points and over degrees of freedom in order to compute the matrix for the cell This routine depends on the problem at hand and is called by assemble() in DoF_Handler class The matrix to be assembled in our case is of the form:

\[ \begin{array}{l} M(i,j).block(0) = \int_{\Omega} a \nabla \phi_i \nabla \phi_j d\Omega + \int_{\Omega} \phi_i \frac{\partial f}{\partial u_0}\Big|_n \phi_j d\Omega \\ M(i,j).block(1) = \int_{\Omega} \phi_i \frac{\partial f}{\partial u_1}\Big|_n \phi_j d\Omega \\ M(i,j).block(2) = \int_{\Omega} \phi_i \frac{\partial f}{\partial u_2}\Big|_n \phi_j d\Omega \end{array} \]

This matrix will be assembled using Equation objects as discussed later.

virtual void cell_matrix ( MatrixVector& cell_matrices,
const typename DoFApplication<dim>::CellInfo& cell );
All application need a member function, cell_residual, that generates the element wise finite element right hand side for the system of equations that is solved. Integration of the rhs of the equations. Here we loop over the quadrature points and over degrees of freedom in order to compute the right hand side for each cell This routine depends on the problem at hand and is called by residual() in DoF_Handler class. Note that this function is called residual because in the case of nonlinear systems the rhs is equivalent to the residual

virtual void cell_residual ( AppFrame::FEVector& cell_vector,
const typename DoFApplication<dim>::CellInfo& cell );
If Dirichlet boundary conditions need to be applied to the problem, the application will have to implement this member function, i.e. dirichlet_bc. This member function is used to set dirichlet boundary conditions. This function is application specific and it only computes the boundary_value values that are used to constraint the linear system of equations that is being solved

virtual void dirichlet_bc ( std::map<unsigned int, double>& boundary_values ) const;
All applications need a member function to solve the global system of equations.

virtual void solve ( AppFrame::FEVector& start,
const AppFrame::FEVectors& rhs );
If we want to use adaptive refinement, we need to estimate the error per cell. This value will be used to refine the mesh as needed.

virtual double estimate ( const AppFrame::FEVectors& sol );
Post-processing. This routine is called at post-processing and it is used to evaluate a functional such as the current density

virtual double evaluate ( const AppFrame::FEVectors& src );
This routine is used to create the output file. A base member function already exists, but in AppCathode we will reimplement it so that the right labels are outputed and so that I can compute and output the source terms.

virtual void data_out ( const std::string &basename,
const AppFrame::FEVectors &src );

The Member Variables

In this section we will look at the member variables that are used in this application so the basis of the simulation can be understood. This are usually protected:

protected :


The first variable is system_management and it is used to store all information related to the system of equations that is being solved such as variable_names, equation_names and couplings between equations.

FuelCell::SystemManagement system_management;


The second member variable is the operating conditions object that was passed to the initial solution object and explained in more detail in the previous section.

protected :
//Operating conditions
Next is the grid object which is derived from the geometries class. As was previously mentioned this object will define the dimensions of the cell, generates the grid for each layer and contains the material and boundary ids.
Instead of creating an object, a boost pointer of the base class is used. This is done so that I can select the appropriate child at run-time after checking the input file.

//Create an object to generate the mesh
boost::shared_ptr< FuelCellShop::Geometry::GridBase<dim> > grid;


The cathode contains oxygen, nitrogen and water vapour, so we need to create an object for each gas in order to compute viscosity, density, etc.


Next the cathode gas diffusion layer (CGDL) and cathode catalyst layer (CCL) are created. Again a pointer is used to the base class for each type of layer. We will then select the appropriate CGDL / CCL once we have read the input file. Using a pointer allows us to code everything independently of the layer we want to use. This allows users to develop their own layers. By making them a child of the base class, for example FuelCellShop::Layer::GasDiffusionLayer<dim>, the application will run for your new layer without any modification. For info on how to create your own layer, please consult the documentation on the base layer clases

boost::shared_ptr<FuelCellShop::Layer::GasDiffusionLayer<dim> > CGDL;
boost::shared_ptr<FuelCellShop::Layer::MicroPorousLayer<dim> > CMPL;
boost::shared_ptr<FuelCellShop::Layer::CatalystLayer<dim> > CCL;
OpenFCST contains a database of materials and layers, but also a database that contains the discrtization of most physical equations relavant to fuel cells. Therefore, we will create an object of the physical processes that are relevant to the cathode.
For the cathode we need an Ohm's law equation for electrons, a Fick's law equation for oxygen and an Ohm's law equation for protons. We also need a source term for the reaction rates.
The equation class used to assemble the cell matrix for electron transport is

The equation class used to assemble the cell matrix for Fick's mass transport.

Equation class used to assemble the cell matrix for proton transport

The Equation class to assemble the cell matrix and cell_residual for the reaction source terms is the last object. After that, we finish the declaration of the class and the namespaces in the app_cathode.h file.

};
}
}
#endif //_FUELCELL__APP_CATHODE_H

Source File

The Member Functions

All source files need to include the .h file. Therefore, the first line in our code is

#include "app_cathode.h"


Since all member functions are in namespace FuelCell::Application is useful to define a shortcut. Also, AppFrame functions are used all the time, so we use using namespace AppFrame so that we do not need to specify the namespace.

namespace NAME = FuelCell::Application;
using namespace AppFrame;
Now, we are ready to start the implementation of all the member functions in AppCathode.

Each application is based on the application base class in appframe (AppFrame::ApplicationBase), in particular the OptimizationBlockMatrixApplication class (AppFrame::OptimizationBlockMatrixApplication). This class is the last in a series of inherited classes starting with ApplicationBase that includes DoFApplication (AppFrame::DoFApplication) and BlockMatrixApplication (AppFrame::BlockMatrixApplication). The DoFApplication contains a handler for the degrees of freedom, Triangulation and the mesh. BlockMatrixApplication handles the matrices and the assembling of the linear systems of equations. OptimizationBlockMatrixApplication was then added by the OpenFCST developers to handle sensitivity analysis. As such all applications are inherited from OptimizationBlockMatrix.

AppCathode constructor will therefore initialize OptimizationBlockMatrixApplication. Also, all equation classes need to be created during the constructor. Finally, we output a line to the terminal stating that this application has been created.

//---------------------------------------------------------------------------
template <int dim>
NAME::AppCathode<dim>::AppCathode(boost::shared_ptr<AppFrame::ApplicationData> data)
:
ficks_transport_equation(system_management,&oxygen, &nitrogen),
electron_transport_equation(system_management),
proton_transport_equation(system_management),
reaction_source(system_management)
{
deallog << "->FuelCell::Application::AppCathode_test-" << dim <<"d"<<std::endl;
}

The destructor for AppCathode does not need to remove any extra pointers since boost pointers have their own memory management strategy.

//---------------------------------------------------------------------------
template <int dim>
NAME::AppCathode<dim>::~AppCathode()
{}
Now that the application is created, the next step is to provide the required information to run a simulation. There are three functions that are used to do this. The first is the declare_parameters function. The application needs to declare the parameters it needs from file, but all other objects used by the applicaiton also need to do the same thing. Therefore, declare_parametes contains a call to declare_parameter for all the Materials, Layers and Equation classes. Note that for the Layers and Geometry, a static function is called. The name provided in the call, e.g. "Cathode gas diffusion layer", is the name of the section in the input file where the information need to be provided.
//---------------------------------------------------------------------------
template <int dim>
void
NAME::AppCathode<dim>::declare_parameters(ParameterHandler& param)
{
system_management.declare_parameters(param);
OC.declare_parameters(param);
electron_transport_equation.declare_parameters(param);
proton_transport_equation.declare_parameters(param);
ficks_transport_equation.declare_parameters(param);
reaction_source.declare_parameters(param);
}
Each one of the declare_parameter calls tells the application what parameters might be passed to it by the .prm file. In the operating conditions class for example, 'entries' such as the temperature of the cell, the cathode pressure or the RH of the cathode are created by the function and are given a default value. The function also tells the application what type of a value to expect, double, int, bool etc. and optionally a short description of the parameter.For example, the OperatingConditions declare_parameters code looks as follows:

//---------------------------------------------------------------------------
void
{
param.enter_subsection("Fuel cell* data");
{
param.enter_subsection("Operating conditions");
{
param.declare_entry ("Temperature cell",
"353", // K, or 80 Celsius
Patterns::Double());
param.declare_entry ("Cathode pressure",
"101325", // Pa, or 1 atm
Patterns::Double(),
"Pressure at the cathode channel in Pa");
param.declare_entry ("Cathode relative humidity",
"0.7",
Patterns::Double(),
"Relative humidity (fraction) in the cathode channel");
param.declare_entry ("Anode pressure",
"101325", // Pa, or 1 atm
Patterns::Double(),
"Pressure at the anode channel in Pa");
param.declare_entry ("Anode relative humidity",
"0.7",
Patterns::Double(),
"Relative humidity (fraction) in the anode channel");
param.declare_entry ("Voltage cell",
"0.6",
Patterns::Double());
param.declare_entry("Voltage drop in the anode",
"0.015",
Patterns::Double());
param.declare_entry ("Open circuit voltage",
"1.23",
Patterns::Double());
}
param.leave_subsection();
}
param.leave_subsection();
}
The prm file is a file containing a list of these parameters as specified by all the declare_parameters functions. It is divided into sections and subsections each having their own label as defined by the declare_parameters functions. For example the operating conditions parameters are found in the "Operating conditions" subsection under the "Fuel cell data" subsection. This section from the prm file is given below:

subsection Fuel cell data
subsection Operating conditions
set Temperature cell = 383 #[K]
set Cathode pressure = 101265 #[Pa] (1 atm)
set Cathode relative humidity = 0.6
set Voltage cell = 0.822 ## Convergence up to 0.66V
end
end
Note that not all the entries declared in the source code are found in the prm file, for example "Anode relative humidity". In this case the default value will be used. Also when defining a parameter, the units should be included. For example the entry for the cell temperature should be "Temperature cell [K]".
Users do not usually need to look at the code to see the parameters that are declared, the DOxygen documentation, under declare_parameters, will explain the values declared.

If an optimisation case is being run, then the application will need to know the parameters that are to be optimised. This is done using the set_parameters function. Here the function stores the names of the parameters to be optimised, in the vector of strings name_dvar, and it's values, in the vector of doubles value_dvar. These are then handled by the optimization_block_matrix_application class. In the application, the function simply calls the set_parameters functions of the relevant classes:
//---------------------------------------------------------------------------
template <int dim>
void
NAME::AppCathode<dim>::set_parameters(const std::vector<std::string>& name_dvar,
const std::vector<double>& value_dvar,
ParameterHandler& param)
{
OC.set_parameters(name_dvar, value_dvar, param);
FuelCellShop::Layer::GasDiffusionLayer<dim>::set_GasDiffusionLayer_parameters(name_dvar, value_dvar, "Cathode gas diffusion layer", param);
FuelCellShop::Layer::MicroPorousLayer<dim>::set_MicroPorousLayer_parameters(name_dvar, value_dvar, "Cathode microporous layer", param);
FuelCellShop::Layer::CatalystLayer<dim>::set_CatalystLayer_parameters(name_dvar, value_dvar, "Cathode catalyst layer", param);
design_var = name_dvar;
design_var_value = value_dvar;
}
The initialise function is the final function in setting up the simulation. It actually parses the prm file, extracts the required values and assigns them to the relevant variables. Again the application function calls the functions of the contributing classes.
In initialize, the ParameterHandler object param already contains the information from the input file. Now this information needs to be distributed to all object in the application.
First, we initialize system_management and operating_conditions (OC):

//---------------------------------------------------------------------------
template <int dim>
void
NAME::AppCathode<dim>::_initialize(ParameterHandler& param)
{
// Initialize parameters in system management:
system_management.initialize(param);
// Initialize coefficients
OC.initialize(param);
}
The contributing classes initialise functions actually extract and set the required values, for example, the call OC.initialize(param); will run the following function:

//---------------------------------------------------------------------------
void
{
param.enter_subsection("Fuel cell data");
{
param.enter_subsection("Operating conditions");
{
T_cell = param.get_double("Temperature cell");
V_cell = param.get_double("Voltage cell");
// Cathode
p_c = param.get_double("Cathode pressure");
c_c = p_c/(R*T_cell)*1E-6; //mol/cm^3
RH_c = param.get_double("Cathode relative humidity");
// Anode
p_a = param.get_double("Anode pressure");
c_a = p_a/(R*T_cell)*1E-6; //mol/cm^3
RH_a = param.get_double("Anode relative humidity");
// Cell voltage
V_cell = param.get_double("Voltage cell");
dV_a = param.get_double("Voltage drop in the anode");
OCV = param.get_double("Open circuit voltage");
if (OCV > voltage_cell_th()) // NOTE: This also initializes E_th
{
OCV = E_th;
param.set("Open circuit voltage", OCV); // For other (old) classes that may use this
}
}
param.leave_subsection();
}
param.leave_subsection();
}
The next step is to set up the grid. For this, we call the #initialize_grid function which will be implemented next. This member function will read in and set variables such as the number of divisions in the CGDL and CCL, the material and boundary IDs and the physical dimensions and then generate the mesh and store it the object this->tr.

// Generate grid and refine
this->initialize_grid(param);
The initialise function in the application actually does more than set up the parameters for the simulation, it also initialises the Materials, Layers and Equation objects and also the kinetics class for cathode catalyst layer. In this case, we first create a vector with all the gases that will exist in the layers.
Next, we call create_GasDiffusionLayer("Cathode gas diffusion layer",param) This function reads the parameter file section Fuel Cell Data > Cathode gas diffusion layer, reads the type of GDL that we would like to use, and then initializes the pointer CGDL to the appropriate GasDiffusionLayer child. In this case, DesignFibrousGDL as we will see when discussing the parameter file.
One the layer has been initialized, we "fill" the layer with the gases using CGDL->set_gases_and_compute(gases, OC.get_pc_atm (), OC.get_T()). This routine is used to store the gases, the temperature and the pressure in the layer and to compute the diffusion coefficients.
The MicroPorousLayer and CatalystLayer objects are initialized using the same method.
// Initialize gases and material classes:
std::vector< FuelCellShop::Material::PureGas * > gases;
gases.push_back(&oxygen);
gases.push_back(&nitrogen);
// Initialize layer classes:
CGDL = FuelCellShop::Layer::GasDiffusionLayer<dim>::create_GasDiffusionLayer("Cathode gas diffusion layer",param);
CGDL->set_gases_and_compute(gases, OC.get_pc_atm (), OC.get_T());
CMPL->set_gases_and_compute(gases, OC.get_pc_atm (), OC.get_T());
CCL = FuelCellShop::Layer::CatalystLayer<dim>::create_CatalystLayer("Cathode catalyst layer", param);
CCL->set_gases_and_compute (gases, OC.get_pc_atm (), OC.get_T());
For the CatalystLayer object, in addition to specifying the type of gas inside the layer, we also need to specify the reactions that we are interested in solvign for since the catalyst layer class contains a kinetics objects which will compute the reaction rates for different reactions. In order to compute the reactions, we need to know the overpotential, temperature and pressure. Every time we call the cell_matrix and cell_residual function, we pass the solution (which will contain the oxygen molar fraction and the two potentials) to the kinetics class. However the kinetics class expects that the temperature and pressure are solution variables (i.e.that it is set at each quadrature point for each cell). (OC.get_pc_Pa(), total_pressure) and (OC.get_T(), temperature_of_REV) are used to specify that these variables are constant and should take the value provided as the first argument.
// Initialise the necessary kinetics parameters in CCL.
std::string name = "ORR";
CCL->set_reaction_kinetics(name);
CCL->set_constant_solution(OC.get_pc_Pa(), VariableNames::total_pressure); //Note: Pressure and temp. can only be set after setting kinetics.
CCL->set_constant_solution(OC.get_T(), VariableNames::temperature_of_REV);
Once the layers have been initialized, it is time to initialize the Equation objects.
Before initializing the objects, we set the kinetics in the reaction source term object. Next, we allow all Equation object to read all their parameters from file: *

// Setting kinetics in the reaction source terms object.
reaction_source.set_cathode_kinetics(CCL->get_kinetics());
// Initialize parameters for physics classes:
electron_transport_equation.initialize(param);
proton_transport_equation.initialize(param);
ficks_transport_equation.initialize(param);
reaction_source.initialize(param);
Next is to prepare the matrix that will contain our system of equations. This involves telling the application how the equations are coupled, i.e. how one solution variable affects the other equations. The blocks for our three equation system are visualised below:

tutorial_blocks.png
If the variable is not in the equation then this block will contain zeros. To save memory, AppFrame will not store this block: if, for example, the membrane potential was not present in the third equation for the solid phase potential then there would only be 8 blocks. In this case the system of equations is fulled coupled as the source term for each equation contains each of the solution variables. To get the information about coupling, a vector "tmp" is created, push_back function is used to get internal cell couplings for all three equations then the couplings are adjusted after including the source term. Finally the couplings are generated in system management.
// Initialize system of equations based on the physical phenomena being solved for:
std::vector<couplings_map> tmp;
tmp.push_back(ficks_transport_equation.get_internal_cell_couplings() );
tmp.push_back(proton_transport_equation.get_internal_cell_couplings() );
tmp.push_back(electron_transport_equation.get_internal_cell_couplings() );
reaction_source.adjust_internal_cell_couplings(tmp);
system_management.make_cell_couplings(tmp);
Given that the grid has now been set up, we can now give it the information about the degrees of freedom (dof) to be associated with each cell. This information is stored using a deal.II object called DoFHandler that will look at the type of finite elements that the simulation is using and allocate space for the dof. The finite elements we are using are defined in the parameter file, under the section Discretisation > Element. In the test case, we are using three first order elements which are defined using: FESystem[FE_Q(1)^3]. If second order elements are required, then we would use: FESystem[FE_Q(2)^3]. To use different elements for an equation, for example first order for the first equation and second order for the other two, we would write FESystem[FE_Q(1)]-FESystem[FE_Q(2)^2]. This is implemented in the deal.II FiniteElement classes.
// Initialize dof per cell for the system of equations and information on
//block sizes and indices
this->remesh_dofs();
The remesh_dofs() will also assign indices to the cells and faces that will be used later in the error estimator.The next step is to initialise the matrix that will store the system of equations.

// Initialize matrices and spartisity pattern for the whole system
this->remesh_matrices();
This is all. Finally, we simply call some routines that are used to print information to screen in order to make sure we have initialized our layers correct.

// Some info for debugging purposes:
//system_management.print_system_info ();
CCL->print_layer_properties ();
//abort();
}
The initialise function above is used to initialise the FCST classes, however the FCST applications are built on a hierarchy of appframe classes that also have to be initialised. For that reason, the main program doesn't call the _initialise function described above, but intialise instead. This function will call the _initialise function but will also call the initialise function of the applications parent class, OptimizationBlockMatrixApplication . This has its own initialise function that will initialise its classes, and also call the initialise function of its parent class, BlockMatrixApplication . This continues on to DoFApplication and ApplicationBase .
//---------------------------------------------------------------------------
template <int dim>
void
NAME::AppCathode<dim>::initialize(ParameterHandler& param)
{
_initialize(param);
}
The grid can be initialized in two ways. If we have a mesh from a file (i.e. read_grid_from_file == true) or we have a mesh from a previous simulation (this->read_in_initial_solution == true), e.g. if running a polarization curve, then, the previous mesh can be read from file and used to initialize the mesh.
Otherwise, use our boost::shared_ptr< FuelCellShop::Geometry::GridBase<dim> > object to generate the mesh using grid->generate_grid(*this->tr);
//---------------------------------------------------------------------------
template <int dim>
void
NAME::AppCathode<dim>::initialize_grid(ParameterHandler& param)
{
// Make sure that grid is appropriate for application
AssertThrow (( (grid->get_mesh_type()).compare("Cathode") == 0 || (grid->get_mesh_type()).compare("CathodeMPL") == 0 || (grid->get_mesh_type()).compare("GridExternal") == 0),
ExcMessage("Only GridExternal, Cathode and CathodeMPL meshes are permitted for AppCathode. Check entry Type of mesh in subsection Grid generation in the parameter file"));
// Check to see whether we are reading the grid from a file
// Must read grid if we are reading a stored solution
bool read_grid_from_file = grid->read_from_file;
// If not reading from a grid from a previous solution,
//check whether we can use one from external source
if (this->read_in_initial_solution || read_grid_from_file)
{
//Read grid_file name and format from parameter handler
std::ifstream grid_file("._mesh_to_be_transfered.msh");
if(!grid_file.fail())
{
deallog << "Reading grid from file" << std::endl;
GridIn<dim> grid_in;
grid_in.attach_triangulation(*this->tr);
grid_in.read_msh(grid_file);
grid_file.close();
read_grid_from_file = true;
}
else
{
// if grid fails to read, generate standard grid, don't read initial solution
deallog << "Grid file not available for reading" << std::endl;
read_grid_from_file = false;
this->read_in_initial_solution = false;
}
}
if(!read_grid_from_file)
{
deallog << "Generating new grid" << std::endl;
grid->generate_grid(*this->tr);
}
}
To actually generate a grid using deal.ii, an object of type triangulation is used (here its pointed to by this->tr). This is a deal.II object that contains information about the geometric and topological properties of a mesh, for example the location of vertices of the cell and how they are connected. The same triangulation object is also used while reading grid from file.The geometries class contains a number of methods for generating a grid depending on whether we are running a cathode, pemfc, anode with MPL etc. simulation. In this case we are running a simple cathode catalyst layer with GDL/MPL simulation. generate_grid will take the dimensions of the fuel cell and divide it up into a mesh depending on how many horizontal and vertical divisions we set. Then it will loop over all cells and their vertices and assign material and boundary ids according to how far a vertex is from the origin in the horizontal direction. Optionally, the grid will then be refined once.

The last part of the setting up the simulation involves providing information about the initial solution to the nonlinear solver. This is done with the init_solution function.

//---------------------------------------------------------------------------
template <int dim>
void
NAME::AppCathode<dim>::init_solution(AppFrame::FEVector& dst)
{

The operating molar fraction of oxygen in the channel and voltage cell are first read from operating_conditions class.

// Obtain the oxygen concentration in the channel and the
// cell potential:
double x_o2 = OC.get_x_o2();
double V_cell = - OC.get_V();
The vector dst has to be initialized to the initial solution. The first step is to resize the dst vector to the size of the solution vector.

// resize vector:
dst.reinit(this->block_info.global);
To input the initial solution to the solution vector, two functions are created called function_map and component_mask. The former is used to relate a material ID to a function type. For example, for the catalyst layer material id, the oxygen concentration should be \( c_0 \).
// Create the function map for each variable and the component mask
std::map<types::material_id, const Function<dim>* > function_map;
std::vector<bool> component_mask(3, false);
We will use a piece-wise constant function as the initial solution. The solution will be constant within each layer and will change in value between layers. Therefore, we need the functions that will provide the value of the solution at each point in the mesh. These functions are defined below and are given to the function_map such that the mole fraction in the GDL is the same as in the channel and MPL and catalyst layer contain half and a quarter of the oxygen respectively. function_map is used to identify the layer via material id.
// --- xo2 ---
const ConstantFunction<dim> f_xo2_GDL(x_o2, 3);
const ConstantFunction<dim> f_xo2_MPL(x_o2/2.0, 3);
const ConstantFunction<dim> f_xo2_CL(x_o2/4.0, 3);
function_map[CGDL->get_material_id()] = &f_xo2_GDL;
function_map[CMPL->get_material_id()] = &f_xo2_MPL;
function_map[CCL->get_material_id()] = &f_xo2_CL;
Next, component_mask is used to identify the solution variable name for which the initial solution is being set up. Finally the deal.II function interpolate is used to distribute the initial solution on the cell.
std::vector< std::string > solution_names( system_management.get_solution_names () );
for (unsigned int i = 0; i<solution_names.size(); i++)
{
if (solution_names[i] == "oxygen_molar_fraction")
component_mask[i] = true;
else
component_mask[i] = false;
}
VectorTools::interpolate( dst,
this->mapping,
this->dof,
function_map,
component_mask );
We repeat the same process for the electrolyte and electrical potential:
// --- fp ---
const ConstantFunction<dim> f_fp(0.0, 3);
function_map[CGDL->get_material_id()] = &f_fp;
function_map[CMPL->get_material_id()] = &f_fp;
function_map[CCL->get_material_id()] = &f_fp;
for (unsigned int i = 0; i<solution_names.size(); i++)
{
if (solution_names[i] == "protonic_electrical_potential")
component_mask[i] = true;
else
component_mask[i] = false;
}
VectorTools::interpolate( dst,
this->mapping,
this->dof,
function_map,
component_mask );
// --- fe ---
const ConstantFunction<dim> f_fe(V_cell, 3);
function_map[CGDL->get_material_id()] = &f_fe;
function_map[CMPL->get_material_id()] = &f_fe;
function_map[CCL->get_material_id()] = &f_fe;
for (unsigned int i = 0; i<solution_names.size(); i++)
{
if (solution_names[i] == "electronic_electrical_potential")
component_mask[i] = true;
else
component_mask[i] = false;
}
VectorTools::interpolate( dst,
this->mapping,
this->dof,
function_map,
component_mask );
}
Please note that our initial solution meets the Dirichlet boundary conditions. This is necessary when setting up the problem.

The simulation has now been set up so we can start to work on the member functions that are used to assemble the local (element-wise) matrix and residual (right hand side).

The two most important functions in the application are the cell_matrix and cell_residual functions. The functions provide the coefficients for the left and right hand side of our linearised system of equations respectively ( \(\frac{dR(u,p)}{du}(-\delta u) = R(u,p)\)).

We will start with the cell_matrix function which implements the integration of the local bilinear form. Here we loop over the quadrature points and over degrees of freedom in order to compute the matrix for the cell. This routine depends on the problem at hand and is called in assemble in BlockMatrixApplication class.

In this case, the matrix to be assembled is given below:

\begin{eqnarray} M(i,j).block(0) & = & \int_{\Omega} a \nabla \phi_i \nabla \phi_j d\Omega + \int_{\Omega} \phi_i \frac{\partial f}{\partial u_0}\Big|_n \phi_j d\Omega \quad \qquad & M(i,j).block(1) & = & \int_{\Omega} \phi_i \frac{\partial f}{\partial u_1}\Big|_n \phi_j d\Omega & M(i,j).block(2) & = & \int_{\Omega} \phi_i \frac{\partial f}{\partial u_2}\Big|_n \phi_j d\Omega \\ M(i,j).block(3) & = & \int_{\Omega} \phi_i \frac{\partial f}{\partial u_0}\Big|_n \phi_j d\Omega & M(i,j).block(4) & = & \int_{\Omega} a \nabla \phi_i \nabla \phi_j d\Omega + \int_{\Omega} \phi_i \frac{\partial f}{\partial u_1}\Big|_n \phi_j d\Omega \quad \qquad & M(i,j).block(5) & = & \int_{\Omega} \phi_i \frac{\partial f}{\partial u_2}\Big|_n \phi_j d\Omega \\ M(i,j).block(6) & = & \int_{\Omega} \phi_i \frac{\partial f}{\partial u_0}\Big|_n \phi_j d\Omega & M(i,j).block(7) & = & \int_{\Omega} \phi_i \frac{\partial f}{\partial u_1}\Big|_n \phi_j d\Omega& M(i,j).block(8) & = & \int_{\Omega} a \nabla \phi_i \nabla \phi_j d\Omega + \int_{\Omega} \phi_i \frac{\partial f}{\partial u_2}\Big|_n \phi_j d\Omega \end{eqnarray}



OpenFCST already contains many equation classes which are used to assemble the local cell matrices. Therefore, assembling the matrices is extremely simple. Simply call the Equation classes you want to solve!
cell_matrix receives a MatrixVector object containing a vector of local matrices corresponding to all the non-zero matrices in the global matrix such that cell_matrices[0] corresponds to M(i,j).block(0), and a CellInfo object that

//---------------------------------------------------------------------------
template <int dim>
void
NAME::AppCathode<dim>::cell_matrix(MatrixVector& cell_matrices,
const typename DoFApplication<dim>::CellInfo& info)
{
In this case, we solve a different set of equations for each layer. In the gas diffusion layer, we solve for Fick's law of diffusion and electron transport. Therefore, we can ask our Equation classes to assemble the local matrices for GDL cells using the information provided by the CGDL class.
A particular layer is identified through its material id for any particular cell. The information of material id is accessed using the member function material_id() in the info object.
Equation classes such as ficks_transport_equation, electron_transport_equation, proton_transport_equation and reaction_source have an inbuilt function assemble_cell_matrix which assembles the local matrix and passes the local matrix to the MatrixVector which is a vector of local matrices, it assembles the local matrices and make them global. The call to assemble_cell_matrix will return M(i,j).block(0) filled based on the properties of the cell and the CGDL.
if (CGDL->belongs_to_material(info.cell->material_id()))
{
ficks_transport_equation.assemble_cell_matrix(cell_matrices, info, CGDL.get());
electron_transport_equation.assemble_cell_matrix(cell_matrices, info, CGDL.get());
}
The same process can be applied the MPL:

else if (CMPL->belongs_to_material(info.cell->material_id()))
{
ficks_transport_equation.assemble_cell_matrix(cell_matrices, info, CMPL.get());
electron_transport_equation.assemble_cell_matrix(cell_matrices, info, CMPL.get());
}
For the catalyst layer, we wish to solve not only for oxygen and electron transport but also for proton transport and for the reaction terms. Therefore, all four Equation objects are called in order to provide the required information in cell_matrices.
else if (CCL->belongs_to_material(info.cell->material_id()))
{
ficks_transport_equation.assemble_cell_matrix(cell_matrices, info, CCL.get());
electron_transport_equation.assemble_cell_matrix(cell_matrices, info, CCL.get());
proton_transport_equation.assemble_cell_matrix(cell_matrices, info, CCL.get());
reaction_source.assemble_cell_matrix(cell_matrices, info, CCL.get());
}
Finally, if the mesh contains cells with a material ID that does not correspond to any of the layers defined in the application, we will ask the program to provide an error message:

else
{
deallog<<"Material id: " <<info.cell->material_id()<<" does not correspond to any layer"<<std::endl;
}
}
Using the process specified above, local cell matrices are formed for all three layers and equations.

This completes the cell_matrix function. We can now move onto the cell_residual and the right hand side (RHS).

The cell_residual function works similarly to the cell_matrix member function. The main difference is that in constructing the RHS we are not assembling a matrix, but a vector, which is why cell_vector is passed in the cell_residual function call.
The vector were are constructing is given by:

\[ R(i),block(i) = \int_{\Omega} \phi_j a (\nabla u)^n d\Omega + \int_{\Omega} \phi_j f(u^n) d\Omega \]


In our case this can be written as:

\begin{eqnarray} R(i),block(0) & = & \int_{\Omega} \phi_j D_{O_2} (\nabla x_{O_2})^n d\Omega + \int_{\Omega} \phi_j \left( \frac{i(u^n)}{4F} \right) d\Omega \\ R(i),block(1) & = & \int_{\Omega} \phi_j \sigma_m (\nabla \phi_m)^n d\Omega + \int_{\Omega} \phi_j \left( \frac{i(u^n)}{F} \right) d\Omega \\ R(i),block(2) & = & \int_{\Omega} \phi_j \sigma_s (\nabla \phi_m)^n d\Omega + \int_{\Omega} \phi_j \left( \frac{i(u^n)}{-F} \right) d\Omega \end{eqnarray}

where \( u^n \) is the solution at the \( n \) iteration in the Newton loop.

cell_residual takes two arguments, a BlockVector that should be initialized to the local right hand side, i.e. element-wise right hand side, and a CellInfo object that contains information regarding the cell finite element, material id, boundary id and the solution at the quadrature points in the cell. This information is used to setup the right hand side.
As in cell_matrix, we first check at which material the cell belongs to and, based on its material id, we use the appropriate Equation class to assemble the right hand side. In this case, this is done by calling the member function assemble_cell_residual.

//---------------------------------------------------------------------------
template <int dim>
void
NAME::AppCathode<dim>::cell_residual(AppFrame::FEVector& cell_vector,
const typename DoFApplication<dim>::CellInfo& info)
{
// -- Assertion before starting routine:
// Make sure vectors are the right size
Assert (cell_vector.n_blocks() == this->element->n_blocks(),
ExcDimensionMismatch (cell_vector.n_blocks(), this->element->n_blocks()));
if (CGDL->belongs_to_material(info.cell->material_id()))
{
ficks_transport_equation.assemble_cell_residual(cell_vector, info, CGDL.get());
electron_transport_equation.assemble_cell_residual(cell_vector, info, CGDL.get());
}
else if (CMPL->belongs_to_material(info.cell->material_id()))
{
ficks_transport_equation.assemble_cell_residual(cell_vector, info, CMPL.get());
electron_transport_equation.assemble_cell_residual(cell_vector, info, CMPL.get());
}
else if (CCL->belongs_to_material(info.cell->material_id()))
{
ficks_transport_equation.assemble_cell_residual(cell_vector, info, CCL.get());
electron_transport_equation.assemble_cell_residual(cell_vector, info, CCL.get());
proton_transport_equation.assemble_cell_residual(cell_vector, info, CCL.get());
reaction_source.assemble_cell_residual(cell_vector, info, CCL.get());
}
else
{
deallog<<"Material id: " <<info.cell->material_id()<<" does not correspond to any layer"<<std::endl;
}
}
Next, we need to define the boundary conditions for the three solution variables. Note that since we are solving a linearized system, the boundary conditions are not for the solution variables oxygen mole fraction, electrical and proton potential, but for their variation, i.e. \( \delta u \) instead of \( u \). Therefore, the variation of each boundary conditon of the nonlinear problem needs to be implemented here.

Dirichlet boundary conditions on the solution variables have a variation of zero, i.e. if the initial solution satisfies the boundary conditions, the Dirichlet boundary conditions on the variations are that the variation should be zero.

Imposing a zero Dirichlet boundary condition is done by the dirichlet_bc function. This function fills a std::map named boundary_values with an unsigned int representing the degree of freedom and the Dirichlet value of the boundary condition. In this case, all Dirichlet boundaries will have a zero value.
//---------------------------------------------------------------------------
template <int dim>
void
NAME::AppCathode<dim>::dirichlet_bc(std::map<unsigned int, double>& boundary_values) const
{
We will analyze one boundary at a time. The boundaries are identified using boundary ids, which are integer numbers that are specified when constructing the mesh. Boundary ids are stored in the grid class and are accessed using the get_boundary_id function by passing a string that identifies the requested boundary. In the code below, we want to apply a boundary condition at the membrane/catalyst layer interface.

The boundary values can be applied to any of the solution variables. For the case of the membrane/catalyst layer interface we would like to apply a Dirichlet boundary conditon to the electrolyte potential, i.e. the Protonic electrical potential variable, since the potential there is specified. The oxygem mole fraction and electrical potential are not specified at that interface. The comp_mask vector is used to denote whether a solution variable should be specified or not. Therefore, we set the comp_mask corresponding to the proton potential to true, while the others are set to false.We have now identified the variable we want to apply the boundary conditions to and we now need to fill out the boundary_values. To setup the values at each degree of freedom the deal.II function interpolate_boundary_values, is used. This function takes the DoFHandler stored in *this->dof, the boundary id, a function which is used to compute the value a the boundary and a comp_mask and it returns boundary_values.

In this case, since we want the Dirichlet boundary on the variation of the solution to be zero, we use the deal.II function ZeroFunction<dim>(this->element->n_blocks()) which returns zero for any block.
std::vector<bool> comp_mask (this->element->n_blocks(), false);
unsigned int i;
// -- boundary component 1: membrane/catalyst layer interface
for (i = 0; i < component_names.size(); ++i)
{
if (component_names[i] == "Protonic electrical potential")
{
comp_mask[i] = true;
break;
}
}
VectorTools::interpolate_boundary_values (*this->dof,
grid->get_boundary_id("c_CL/Membrane"),
ZeroFunction<dim>(this->element->n_blocks()),
boundary_values,
comp_mask);
For the other two boundary conditions the same process if followed. Since we used a break command once a variable is set to true, then, we reset it to false and then set to true the variable of interest for each boundary condition we want to apply.

The area under the rib is in contact with the end plate and therefore allows the transport of electrons, while the area under the gas channel allows the transport of oxygen. The comp_mask is updated each time to reflect this, before calling the interpolate_boundary_values function again. Another function, apply_boundary_values, will be used later to apply the BCs to the system matrix.
// -- boundary component 2: area underneath ribs
comp_mask[i] = false;
if (this->boundary_fluxes == false)
{
for (i = 0; i < component_names.size(); ++i)
{
if (component_names[i] == "Electronic electrical potential")
{
comp_mask[i] = true;
break;
}
}
VectorTools::interpolate_boundary_values (*this->dof,
grid->get_boundary_id("c_BPP/GDL"),
ZeroFunction<dim>(this->element->n_blocks()),
boundary_values,
comp_mask);
}
// -- boundary component 3: area underneath gas channel
// No Dirichlet boundary conditions for phases but we have Dirichlet BC for gas composition. Since for the linear
//system we solve for delta_u, the dirichlet BC for delta_u are to have the value to be zero
comp_mask[i] = false;
for (i = 0; i < component_names.size(); ++i)
{
if (component_names[i] == "Oxygen molar fraction")
{
comp_mask[i] = true;
break;
}
}
VectorTools::interpolate_boundary_values (*this->dof,
grid->get_boundary_id("c_Ch/GDL"),
ZeroFunction<dim>(this->element->n_blocks()),
boundary_values,
comp_mask);
}
Now that system of equations has been set up and the boundary conditions applied, we can develop solve function.

The solve function is called from the Newton solver to solve our system of equations. The assembly is not actually done in this function, this is handled by AppFrame. The residual is computed by the DoFApplication , a parent of the application class. This class will assemble the RHS for the whole fuel cell, but as we have seen it will need to call cell_residual from our application in order to get the coefficients from a single cell. Similarly, the stiffness matrix is assembled by the BlockMatrixApplication class and it will call the cell_matrix function from our application when assembling the matrix for the whole fuel cell.The solve function has two input variables. The FEVector (start) is where the solution will be stored after solving the problem. The FEVectors is a collection of vectors and is where the solution and the right hand side are stored.

Note that FEVectors is a collection of pointers to BlockVector objects. This individual vectors that are being pointed to are labelled with a string which allows us to access them later. The FEVector is simply a single BlockVector object. Newton solver creates the rhs and uses it to contain the solution vector and the residual vector.
//---------------------------------------------------------------------------
template <int dim>
void
NAME::AppCathode<dim>::solve(AppFrame::FEVector& start,
const AppFrame::FEVectors& rhs)
{
The first step is to assemble our global matrix. Assembling the global system however is not always necessary. Specially if the change in the residual at the Newton iteration is very small. Therefore, we ask using notification if the system needs to be assembled.
Notifications is setup in the nonlinear solver which can tell the application not to update the system matrix so it will not enter the block below. In most case, the global system needs to be assembled.

if (this->notifications.any())//if (this->assemble_reasons)
{
In order to assemble the global system, we call the assemble member function from the parent BlockMatrixApplication. Assemble needs the solution at the pervious Netwon step, therefore, before calling assemble, we extract the solution vector from the rhs FEVectors object by searching for the vector corresponding to the "Newton iterate" string. Then we call the assemble function in the BlockMatrixApplication class which will assemble the system matrix before clearing any notifications.

unsigned int ind = rhs.find_vector("Newton iterate");
sol.add_vector(rhs.vector(ind),"Newton iterate");
this->assemble(sol); //Note the second component of
this->notifications.clear();//this->clear_assemble();
}
After the global matrix is assembled, the next step is ensuring that our matrix is not singular using the SolverUtils class. Since not all equations are solved in all domains the repair_diagonal function will search the main diagonal and if it finds a zero element, and the corresponding RHS value is zero, it will replaced the zero on the diagonal with an average value from diagonal elements. This is equivalent to adding an equation of type \( \delta u_{DOF,i} = 0\). This is necessary to remove the equations for the domains for which an equation is not solved and it does not affect the solution. Since both the RHS and LHS are zero, adding a 1 in the diagonal is equivalent to saying that the solution should not be updated in that region.
// Eliminate zeros in the diagonal
SolverUtils diagonal;
unsigned int index_Newton_iterate = rhs.find_vector("Newton iterate");
diagonal.repair_diagonal(this->matrix, start, rhs.vector(index_Newton_iterate));
// Copy RHS to solution vector
unsigned int index_Newton_residual = rhs.find_vector("Newton residual");
start = rhs.vector(index_Newton_residual);
Before we do anything else, we make sure that the start vector has been initialzed to the proper size. Here we compare the size of the solution at the previous time step to the vector where the variation of the solution, i.e. our solution to the linear system will be stored.

// Make sure that the solution is of the same size as the RHS
Assert(start.size() == rhs.vector(index_Newton_iterate).size(),
ExcDimensionMismatch(start.size(), rhs.vector(index_Newton_iterate).size()));
Two linear solvers "UMFPACK" or "GMRES" can be used to solve the system of equations.

UMFPACK is a direct solver. It is robust and fast for problems that are fully coupled like in our case. Unfortunately, it has large memory requirements so it can only be used for small problems like a cathode in two dimensions.

GMRES is an iterative solver. It is one of the very few iterative solvers that can be used to solve non-symmetric, non-positive defined matrices, so we can use it here. In order to improve its performance it is recommeded to use a preconditioner. The preconditioners modies the global matrix to make it easier to solve in minimum number of iterations. GMRES is documented in the solver class in deal.II.

In this case we hardcoded UMFPACK as our solver so only that solver is explained below:

std::string solver_name ("UMFPACK"); //("UMFPACK") | ("ILU-GMRES");
If GMRES is used we simply setup a preconditioner and call GMRS to sovle the problem. Note that the matrix, with boundary conditions already applied is stored in this->filtred_matrix.

//*********** GMRES with ILU preconditioner *****
if (solver_name.compare("ILU-GMRES") == 0) //Could also use if (strcmp (solver_name,"ILU-GMRES") == 0)
{
solver.solve( this->filtered_matrix,
start,
rhs.vector(index_Newton_residual),
10000,
1e-12,
prec.preconditioner );
}
If solver "UMFPACK" we cannot use this->filtered_matrix since the interface to the solver only allows for BlockMatrices to be solved. Fortunately, in addition to this->filtred_matrix we also store the global matrix without the boundary conditions already applied to it, so we can use that matrix.

So for UMFPACK, we first apply the boundary conditions to this->matrix, the RHS and solution. The call this->dirichlet_bc is actually calling the function in our application as it is a pure function in BlockMatrixApplication, the parent class. The bcs are applied using the deal.II function apply_boundary_values

After applying the boundary conditions, we create an object of type LinearSolvers::SparseDirectUMFPACKSolver and use it to solve the problem using the solve member function. Solve takes the global matrix and right hand side and returns the solution.
//*********** UMFPACK *****
else if (solver_name.compare("UMFPACK") == 0)
{
// Apply BC to matrix, rhs and sol
std::map<unsigned int, double> boundary_values;
this->dirichlet_bc(boundary_values);
MatrixTools::apply_boundary_values(boundary_values,
this->matrix,
start,
start);
solver.solve(this->matrix,
start,
start);
}
If you wish to implement another solver, you could do it here:

else
{
const std::type_info& info = typeid(*this);
deallog<<"Solver not implemented in Class "<<info.name()<<". Member function solve()"<<std::endl;
abort();
}
Finally, for adaptive refinement we need to take care of the solution at hanging ndes. This is done using the deal.II function distribute.

// Distribute solution:
//Return the solution to all the nodes taking into accout the hanging nodes
this->hanging_node_constraints.distribute(start);


The next function to consider is the estimate function. This is used by the adaptive refinement class to find the cells that need coarsening or refining. The member function takes the FEVectors with the solution and uses it to compute an internal variable this->cell_errors which estimates the error in each cell using an a posteriori error estimator.

//---------------------------------------------------------------------------
template <int dim>
double
NAME::AppCathode<dim>::estimate(const AppFrame::FEVectors& vectors)
{

The FEVectors object passed to the function is defined in the adaptive refinement class simply as the solution vector. This function will approximate the error per cell using the Kelly error estimator. The basic principle behind the estimator is to observe the jumps in the gradients of the solution over the faces of the cells, in effect measuring the local smoothness of the solution at each cell. This difference is stored in a Vector object called cell_errors, a member function of DoFApplication. This will be used later by the remesh function to choose the cells in most need of refining or coarsening.

The first step is to resize the cell_errors Vector to be the size of the number of active cells in the entire.

// Reinitialize error estimator vector and set Newmann boundaries:
this->cell_errors.reinit(this->tr->n_active_cells());
Then, we extract the solution from the vectors and store it in the required object.

// Extract soluton from FEVectors
unsigned int ind = vectors.find_vector("Solution");
const BlockVector<double>& sol = vectors.vector(ind);
The next step is to use a vector of bools, comp_mask to choose which of our solution variables will be used to compute the error estimator:

//component_mask denote which components of the finite element space shall be used to
//compute the error estimator. If it is left as specified by the default value (i.e. an empty array), all components are
//used.
std::vector<bool> comp_mask (this->element->n_blocks());
// Set refinement flags for material_id = 1
// -- material_id 1: catalyst layer
comp_mask[0] = true; // - comp 1 = x02
comp_mask[1] = true; //true;
comp_mask[2] = true; //true; // - comp 2 = phi_m (membrane phase potential)
Here we are setting the mask so that the first of the solution variables will be used, i.e. the oxygen molar fraction.Finally, we call the KellyErrorEstimator in deal.II. The estimate function needs a map that describes the location of the Neumann boundary conditions and their values. However in this case we do not have Neumann boundary conditions, so we pass an empty object of type function map. This function will now call the estimate function from the deal.II KellyErrorEstimator class.

KellyErrorEstimator<dim>::estimate(*this->dof,
QGauss<dim-1>(3),
typename FunctionMap<dim>::type(),
sol,
this->cell_errors,
comp_mask);
return 0.0;
}
These are all the classes that are needed by the solver and adaptive refinement loop, the last function to be implemented is the post-processing routine.

data_out is used to create the .vtk files that will be used to visualise our solution in Paraview. This member fuction takes a string with the name of the output file we would like to create and the FEVectors object with the solution.

//---------------------------------------------------------------------------
template <int dim>
void
NAME::AppCathode<dim>::data_out(const std::string &basename,
const AppFrame::FEVectors &vectors)
{
When calling this function in adaptive_refinement, we pass the name of data we wish to print out. Once a simulation has run the code will print out both the grid and the solution .vtk files at each refinement level. In data out we are creating the vtk files so we pass the string "fuel_cell-sol-cycle" plus the cycle number of the adaptive refinement loop. We also pass the FEVectors object that contains the solution and the residual.The step in the code is to extract the solution vector from the FEVectors object:

// Read the solution vector out of the many vectors:
unsigned int i = vectors.find_vector("Solution");
const BlockVector<double>& src = vectors.vector(i);
const std::string suffix = this->d_out.default_suffix();
if (suffix == "") return;
We also begin the initialisation of the d_out object, which is an DoFApplication object for writing files. d_out is of type DataOut from deal.II. We ask it for the suffix which will be appended to the basename passed to the data_out function. This suffix is .vtk in the case of the data_out function. If we were printing the grid, then it would be .eps.
// Output solution with its corresponent names:
std::vector<std::strings> solution_names;
solution_names.push_back("Oxygen_molar_fraction");
solution_names.push_back("Protonic_electrical_potential");
solution_names.push_back("Electronic_electrical_potential");
AppFrame::DoFApplication<dim>::data_out(basename,vectors,solution_names);
Next we create a vector of the solution names and then pass it to the d_out object.

As well as printing out the solution variables we will also print out the value of the source term at each point in our grid. However when we computed the current density in the cell_residual and cell_matrix classes the values were stored in local (to the function) variables. As such we no longer have access to them so we must compute them again. We start by reinitialising our problem: putting the solution in the required format and creating an object to hold the values of the source term:

if (this->element->n_base_elements() == 1)
{
source.reinit(src);
AppFrame::FEVector reaction_rate(this->element->n_blocks(),
this->element->base_element(this->element->block_to_base_index(0).first).dofs_per_cell);
Next we reinitialise the quadrature and finite element objects:

const Quadrature<dim> dummy_qr (this->element->base_element(0).get_generalized_support_points());
std::vector<FEValues<dim>*> fe(this->element->n_base_elements());
for (unsigned int i=0; i<fe.size(); ++i)
fe[i] = new FEValues<dim> (*this->mapping,
this->element->base_element(0),
dummy_qr,
UpdateFlags(update_values | update_q_points | update_JxW_values));
const unsigned int dofs_per_cell = this->element->dofs_per_cell;
// Create vector that related degrees of freedom of the cell with dof of the global system (to assembly global matrix)
std::vector<unsigned int> indices_org (dofs_per_cell);
std::vector<unsigned int> indices (dofs_per_cell);
Next we are going to loop over all the cells in our grid, and initialise the degrees of freedom for each cell.
for (cell = this->dof->begin_active(); cell != this->dof->end(); ++cell)
{
// initialize the vector that relates local element dof to global dof
cell->get_dof_indices (indices_org);
for (unsigned int i=0; i<indices.size(); ++i)
indices[this->block_info.local_renumbering[i]] = indices_org[i];
//initialize shape function values for the cell for (unsigned int i=0; i<fe.size(); ++i)
fe[i]->reinit ((typename Triangulation<dim>::cell_iterator) cell);
We will then check to see if the cell is in the catalyst layer, if it is we compute the current density in a very similar manner to how it was done in the cell_residual class.

// set material id
unsigned char material_id = cell->material_id ();
if (material_id == 4 || material_id == 6) //the material is the catalyst layer
{
// Since all fe are the same use fe(0) for all elements:
const FEValuesBase<dim>& fe_values = *fe[0];
// get integration points
const unsigned int n_quad = fe_values.n_quadrature_points;
std::vector<Point<dim> > point(n_quad);
point = fe_values.get_quadrature_points();
// Create a vector where the values of coefficients at quadrature points are stored:
std::vector<double> values(n_quad);
// Create a vector to store solution:
std::vector<std::vector<double> > solution(this->element->n_blocks(), std::vector<double> (n_quad, 0.));
//
for (unsigned int i=0; i<this->element->n_blocks(); ++i)
{
fe_values.get_function_values(src,
make_slice(indices, this->block_info.local.block_start(i), fe_values.dofs_per_cell),
solution[i]);
}
// [BC] calculate source term contributions at each quadrature point.
//Compute values
std::vector< FuelCellShop::SolutionVariable > solution_variables;
solution_variables.push_back( FuelCellShop::SolutionVariable(&solution[system_management.solution_name_to_index("oxygen_molar_fraction")], oxygen_molar_fraction) );
solution_variables.push_back( FuelCellShop::SolutionVariable(&solution[system_management.solution_name_to_index("protonic_electrical_potential")], protonic_electrical_potential) );
solution_variables.push_back( FuelCellShop::SolutionVariable(&solution[system_management.solution_name_to_index("electronic_electrical_potential")], electronic_electrical_potential) );
CCL->set_solution(solution_variables);
CCL->current_density(values);

Next we account for the stoichiometry of the problem and add the source terms to the solution FEVectors object:

for (unsigned int q=0; q<n_quad; ++q)
{
//
reaction_rate.block(0)(q) = values[q]/(4.*Constants::F()); //Note I loop over the indices of the first component which
reaction_rate.block(1)(q) = values[q];
reaction_rate.block(2)(q) = -values[q];
}
// Store in the global vector:
//unsigned int size_source = source.size();
//std::cout<<"SIZE SOURCE"<<size_source<<"and SIZE REACTION_RATE "<<reaction_rate(3)<<std::endl;
for (unsigned int i=0; i<this->element->dofs_per_cell; ++i)
source(indices[i]) = reaction_rate(i);
}
}

The fe object is then deleted, and the values of the source terms as well as their names are added to the d_out object.

// names for source terms
std::vector<std::string> source_names;
source_names.push_back ("source_o2");
source_names.push_back ("source_phi_m");
source_names.push_back ("source_phi_s");
this->d_out.add_data_vector(source, source_names, DataOut_DoFData<DoFHandler<dim>, dim>::type_dof_data, this->data_interpretation);
this->d_out.build_patches();
} //end if
Once we have gone over all the cells we are ready to output the .vtks. We create the name of each file,filename and use this->d_out.write to make the .vtk for that run of the adaptive refinement.

const std::string filename = basename + filename1 + suffix;
deallog << "Datafile:" << filename << std::endl;
std::ofstream of (filename.c_str ());
this->d_out.write (of);
this->d_out.clear();
}

This completes the app_cathode application and now we will move onto how to set up the simulations using parameter files.

The parameter file

When running a simulation, the FCST code requires two parameter files. The first is the main parameter file mentioned when discussing the different 'levels' in the FCST code. It is used by SimulatorBuilder to set up the simulation. It contains key parameters such as the application, solver and solver method, and tells the simulation if we are using Dakota and the name of the file that contains the physical parameters (among other parameters as we will see). The main parameter for the test case developed for app_cathode is given below. Note that these files are located in the /data folder. There are three folders, analysis, parametric and testing. The analysis folder contains the files needed to run a single simulation, i.e. it will return a single point on a polarisation curve. The parametric folder contains the files needed for a parametric study, i.e. it can return a full polarisation curve. Both of these are templates based around a test case and can be modified to create your own simulations. The final folder is the test case, which also contains a file with expected results from the test case. This file should not be modified and should only be run in order to test the code.

######################################################################
#
# This file is used to simulate an cathode and to obtain
# a polarisation curve.
#
#
# Copyright (C) 2011 by Marc Secanell
#######################################################################
subsection Simulator
set simulator name = cathode
set simulator parameter file name = data_app_cathode_test.prm
set solver name = Newton3ppC
set Dakota direct = false
end
As we can see comments in these .prm files are denoted using a # symbol. The structure is the same as was discussed in the declare_parameters function of the application. Note that we are not saying what the solver method is. Currently, only adaptive refinement has been implemented so this has been set as the default value in the SimulationSelector class. Therefore we do not need to set it in the parameter file. This should always be considered when setting up a simulation, parameters that you do not set will have default values that will be used and there will be no warning if the default is used.

Now we can move onto the file containing the bulk of the simulation parameters. The file that will be examined is again for the app_cathode test case. It contains physical data about the fuel cell we wish to model, including dimensions, operating conditions, properties etc. However it also contains information about the grid, the discretisation method, solver information, optimisation and the output. Here, we use the parameter file that is stored in /data/cathode//Secanell_EA07_Numerical_Optimization_PEMFC_Cathode_Electrodes.

######################################################################
#
# File used to reproduce the results from the following paper:
# M. Secanell, B. Carnes, A. Suleman and N. Djilali, "Numerical optimization of proton exchange membrane fuel cell cathodes", Electrochimica Acta, 52, 2007, 2668-2682
#
# This file is used to simulate an cathode and to obtain
# a single point on a polarisation curve. It is based on
# the test case and will be called by the
# main_app_cathode_analysis.prm file.
#
# Copyright (C) 2011-13 by Marc Secanell
#
######################################################################


The first section of the input file details the grid generation. We can select the type of geometry we want, e.g. a mesh from file, a cathode or a cathodeMPL. In this case a cathode is used.
The Initial refinement states that the initial mesh will be refined twice before the solution starts. Also, Refinement = adaptive states that we will be using adaptive refinement to solve the problem. The properties are specified in a subsequent subsection.
The next two parameters specify how to organize the degrees of freedom. We usually select to Sort by component. If the Sort Cuthill-McKee parameter is set to true then we will use the Cuthill-McKee algorithm to arrange the degrees of freedom, leading to a system matrix with non-zero terms more localised around the main diagonal. Instead we are using sort by component which will allow use to use the block matrix format. Note that the refinement parameter has a comment after adaptive. This just details other options that could be used for this parameter, i.e. we could use global instead of adaptive refinement.

###############
subsection Grid generation
set Type of mesh = Cathode # Cathode | CathodeMPL | File
set Initial refinement = 2
set Refinement = adaptive #global | adaptive
set Sort Cuthill-McKee = false
set Sort by component = true
When selecting a cathode geometry, we let the OpenFCST mesh generator generate the geometry for us. Therefore, we need to specify the dimension of the cell as well as a number id that specifies the material type and the boundary type. The parameters that need to be specified for use by the internal mesh generator are given in the following section:

subsection Internal mesh generator parameters
####
subsection Dimensions
set Cathode current collector width [cm] = 0.1
set Cathode channel width [cm] = 0.1
set Cathode CL thickness [cm] = 1.18e-3
set Cathode GDL thickness [cm] = 1.0e-2
end
####
#subsection Material ID
# set Cathode catalyst layer = 4
# set Cathode gas diffusion layer = 2
#end
####
subsection Boundary ID
set c_CL/Membrane = 1
set c_BPP/GDL = 2
set c_Ch/GDL = 3
set c_GDL/CL = 255
end
end
####
end
Note that Material ID and Boundary IDs are used to relate a layer to a domain in the input file as we will show in the layer section. Note that the symmetric boundaries are given a value of 0 while interior boundaries, such as the one here between the CCL and GDL, must be denoted with 255.

Next, we move to the System management section. Here we specify the name of the solution variables and equations. For the cathode problem we have three unknowns and three equations as specified.

###############
subsection System management
set Number of solution variables = 3
subsection Solution variables
set Solution variable 1 = oxygen_molar_fraction
set Solution variable 3 = electronic_electrical_potential
set Solution variable 2 = protonic_electrical_potential
end
subsection Equations
set Equation 1 = Ficks Transport Equation - oxygen
set Equation 3 = Electron Transport Equation
set Equation 2 = Proton Transport Equation
end
end
Next we move to the discretisation parameters. In this section we select the type of finite element and the type of quadrature integration. We are using three first order equations, however the commented out parameter part shows another potential option, i.e. a third order element for the first equation and two first order elements. To recap on the how the finite elements are set: In the test case, we are using three first order elements which are defined using: FESystem[FE_Q(1)^3]. If second order elements are required, then we would use: FESystem[FE_Q(2)^3]. To use different elements for an equation, for example first order for the first equation and second order for the two other two, we would write FESystem[FE_Q(1)]-FESystem[FE_Q(2)^2]. In the matrix and residual subsections we are setting the order of the quadrature points relative to the the degrees of freedom. By setting the value to -1 we are actually saying that the order is one more than that of the degrees of freedom.
###############
subsection Discretization
set Element = FESystem[FE_Q(1)^3] #FESystem[FE_Q(3)-FE_Q(1)^2] #FESystem[FE_Q(1)^3] #System of three fe
subsection Matrix
set Quadrature cell = -1
set Quadrature face = -1
end
subsection Residual
set Quadrature cell = -1
set Quadrature face = -1
end
end
Next, we define the parameter for adaptive refinement. Once the grid is created, there will be 2 global refinements (Number of Refinements = 2) before any solvers are called. In general we will always use adaptive refinement in our simulations rather than global, while the number of refinements is the number of times we adaptively refine our grid, leading to 5 calls to the Newton solver.
###############
subsection Adaptive refinement
set Number of Refinements = 2
set Output initial solution = false
set Output intermediate solutions = false
set Output intermediate responses = true
set Output final solution = true
set Output solution for transfer = false
set Read in initial solution from file = false
end
Next is the information for the Newton solver. Here we set parameters such as the tolerance and the maximum number of steps for each iteration.
###############
subsection Newton
set Assemble threshold = 0.0
set Debug level = 0
set Debug residual = false
set Debug solution = false
set Debug update = false
set Max steps = 100
set Reduction = 1.e-20
set Tolerance = 1.e-5
end
Now we can move onto the physical parameters describing the fuel cell.First, we set the operating conditions for the fuel cell:

subsection Operating conditions
set Temperature cell = 363 #[K]
set Cathode pressure = 202650 #[Pa] (1 atm)
set Cathode initial oxygen mole fraction (prior to humidification) = 1.0
set Cathode relative humidity = 0.75
set Voltage cell = 0.9 ## Equivalent to dV = 1.2 - 0.9 = 0.3V in paper since OCV = 1.2V
end
Next we can move onto layer properties. First, let us set up the gas diffusion layer.
First, we specify the type of gas diffusion layer we would like to study. There are several types available such as SGL24BA, DummyGDL and DesignFibrousGDL. DesignFibrousGDL uses effective medium theories in order to determine the effective diffusivity, and electrical conductivity of the catalyst layer based on kinetic theory of gases diffusion coefficients for the gas and bulk properties for the fibers respectively.
Once the layer type is defined we select the Material id of the layer. Now, the cells in the mesh with id 2 (Cathode gas diffusion layer) as shown in the GridGeneror section, will take the properties of this layer.
Finally, I specify the bulk electrical conductivity and the method to obtain effective properties, in this case Bruggemann even tough Percolation | Tomadakis | Mezedur methods are also implemented.

subsection Cathode gas diffusion layer
set Gas diffusion layer type = DesignFibrousGDL
set Material id = 2
####
subsection DesignFibrousGDL
######### Composition: #########
set Porosity = 0.8
######### Gas transport #########
set Electrical conductivity = 279.5
## Anisotropy
set Anisotropic transport = false # (default) false
set Method effective transport properties in pores = Bruggemann # (default) Bruggemann | Given | Percolation | Tomadakis | Mezedur
set Method effective transport properties in solid = Bruggemann # (default) Bruggemann | Given | Percolation
end
end
Next, we define the catalyst layer properties. Again, we set the material id that corresponds to the layer and select the appropriate layer. A catalyst layer computes all the effective properties but also the current density in the layer.
Here we have three options, a DummyCL which would allow the user to set the effective properties directly, a HomogeneousCL and an AgglomerateCL. A homogeneousCL implements a macro-homogeneous model. An AgglomerateCL implements a multi-scale model with an agglomerate model (either analytical or numerical) being solved to predict the current density.
In this case, since the article we are trying to reproduce used a macro-homogeneous layer, we select HomogeneousCL as the type of CL.

####
subsection Cathode catalyst layer
set Material id = 4
set Catalyst layer type = HomogeneousCL #[ DummyCL | AgglomerateCL | HomogeneousCL ]
Once the catalyst layer has been selected, we need to specify the materials that form the layer. In this case, we need to specify the type of catalyst, catalyst support and electrolyte. We select Platinum, Carbon black and Nafion repectively. For each one of the materials, we need to specify its properties since this properties are need to obtain effective transport properties and also to predict the current density in the layer.
Note that the platinum section is used to describe its kinetic properties rather than physical properties. The physical properties, such as density, used are the default values in the platinum class. Again it is important to keep this in mind when creating a prm file. Also note that the reference concentration has been modified to include the contribution from Henrys Law.

set Catalyst type = Platinum
set Catalyst support type = Carbon Black
set Electrolyte type = Nafion
####
subsection Materials
####
subsection Platinum
set Method for kinetics parameters (ORR) = Given #Parthasarathy
set Cathodic transfer coefficient (ORR) = 1.0
set Given Open Cell Voltage (ORR) [V] = 1.2
set Reference exchange current density (ORR) [uA/cm2] = 0.0305
set Reference oxygen concentration (ORR) = 1.2e-6
#set Reference proton concentration (ORR) = 1.818e-3
set Oxygen reaction order (ORR) = 1.0
end
####
subsection Nafion
set Method to compute proton conductivity = Constant # Options are: Constant|Springer|NRE211|Iden11
set Proton conductivity [S/cm] = 0.06 # Proton conductivity inside the membrane, S/cm
set Oxygen diffusion coefficient [cm^2/s] = 9.726e-6 # From J. Peron et al., “Properties of Nafion NR-211 membranes for PEMFCs”
set Henry's Law Constant for Oxygen [Pa cm^3/mol] = 3.52611e10
end
subsection Carbon Black
set Electrical conductivity [S/cm] = 32.64 # Electrical conductivity of a group of particles packed to have 0% porosity. In S/cm
end
end
####
Once the material has been specified, we can specify the amount of each one of the materials and also the methodology that we need to use to compute the effective properties. subsection ConventionalCL is used to specify such properties for both HomogeneousCL and AgglomerateCL since both layer have the same macro-scale parameters. For an AgglomerateCL model an additional section is needed with further parameters (see data/cathode/Secanell_EA07_MultiVariable_Optimization_PEMFC_Cathodes_Agglomerate_Model).
In this case, we specify the amount of Pt in our support, the platinum loading (in mg/cm3 !!!), the electrolyte loading and that the effective properties should be obtained using Bruggemann's method.

##
subsection ConventionalCL
set Platinum loading on support (%wt) = .2
set Platinum loading per unit volume (mg/cm3) = 281.6
set Electrolyte loading (%wt) = 0.30
set Method to compute active area = Marr
######### Pore network #########
set Method effective transport properties in pores = Bruggemann
######### Solid network #########
set Method effective transport properties in solid phase = Bruggemann
######### Ionomer network #########
set Method effective transport properties in electrolyte phase = Bruggemann
end
##
end
end
Once all the layers are specified, we specify if we would like to compute any functionals at post-processing. In this case, we would like to compute the current.

###############
subsection Output Variables
set num_output_vars = 1
set Output_var_0 = current
end
Finally, we need to set the output parameters for the simulation. In this case we only wish to set the format of the output. The data output is set to .vtk, which can be read by Paraview and used to visualise our solution profiles. The grid is outputted in a simple .eps files that can be used to view the grid. However it can't be read by the code and used as a grid in a new simulation.

###############
subsection Output
subsection Data
set Output format = vtk #tecplot
end
subsection Grid
set Format = eps
end
end
This, along with default values, is enough to describe the fuel cell we wish to model.

Results

We can see the results from our simulation in the picture below along with the domain of our simulation. The simulation produces a .vtk file that show the 2D profiles of our three solution variables. We can also produce an image of the source term for each of our equations, i.e. the current production profile. The image shows both the GDL and CCL for the oxygen and electronic potential profiles. However because there is no ionomer and no current production in the GDL, we have cut out the GDL for the protonic potential and source term profiles so as to better visualise the CCL. The CCL is still visible in the oxygen molar fraction profile, on the far left, due to the difference in the oxygen diffusion coefficient between the CCL and the CGDL. Note also that the source term profile is heavily weighted to the upper left. This would indicate a relatively slow transport of protons through the membrane. We can also clearly in the oxygen and solid phase potential profiles where the rib and gas channel are.

The software that is normally used to visualise the results is Paraview, an open source data analysis and visualisation package. An introduction and some exercises are available on the ESDL repository, under the manuals section. Also, the following lecture by Wolfgang Bangerth and Timo Heister is also very useful.
tutorial_domain.png
tutorial_pic.png

The application can also be used with Dakota to produce a polarization curve. The necessary files for producing a polarization curve are given in data/cathode/Secanell_EA07_Numerical_Optimization_PEMFC_Cathode_Electrodes/parametric. Below are the values obtained from a polarization curve study:

%eval_id V_cell obj_fn
1 0.828 -1.510047073
2 0.8404444444 -1.22595
3 0.8528888889 -0.984834143
4 0.8653333333 -0.7816117882
5 0.8777777778 -0.6118229935
6 0.8902222222 -0.4715430691
7 0.9026666667 -0.3573065763
8 0.9151111111 -0.2659409983
9 0.9275555556 -0.1944040981
10 0.94 -0.1396915213
This could then be used to produce a polarisation curve.

tutorial_polarization.png

The plain program