Source code for pymatgen.analysis.interface_reactions

# coding: utf-8
# Copyright (c) Pymatgen Development Team.
# Distributed under the terms of the MIT License.

"""
This module provides class to generate and analyze interfacial reactions.
"""

import warnings
import numpy as np
import matplotlib.pylab as plt

from pymatgen import Composition
from pymatgen.analysis.phase_diagram import GrandPotentialPhaseDiagram
from pymatgen.analysis.reaction_calculator import Reaction

__author__ = "Yihan Xiao"
__copyright__ = "Copyright 2011, The Materials Project"
__version__ = "1.0"
__maintainer__ = "Yihan Xiao"
__email__ = "eric.xyh2011@gmail.com"
__status__ = "Production"
__date__ = "Aug 15 2017"


[docs]class InterfacialReactivity: """ An object encompassing all relevant data for interface reactions. """ EV_TO_KJ_PER_MOL = 96.4853 def __init__(self, c1, c2, pd, norm=True, include_no_mixing_energy=False, pd_non_grand=None, use_hull_energy=False): """ Args: c1 (Composition): Composition object for reactant 1. c2 (Composition): Composition object for reactant 2. pd (PhaseDiagram): PhaseDiagram object or GrandPotentialPhaseDiagram object built from all elements in composition c1 and c2. norm (bool): Whether or not the total number of atoms in composition of reactant will be normalized to 1. include_no_mixing_energy (bool): No_mixing_energy for a reactant is the opposite number of its energy above grand potential convex hull. In cases where reactions involve elements reservoir, this param determines whether no_mixing_energy of reactants will be included in the final reaction energy calculation. By definition, if pd is not a GrandPotentialPhaseDiagram object, this param is False. pd_non_grand (PhaseDiagram): PhaseDiagram object but not GrandPotentialPhaseDiagram object built from elements in c1 and c2. use_hull_energy (bool): Whether or not use the convex hull energy for a given composition for reaction energy calculation. If false, the energy of ground state structure will be used instead. Note that in case when ground state can not be found for a composition, convex hull energy will be used associated with a warning message. """ self.grand = isinstance(pd, GrandPotentialPhaseDiagram) # if include_no_mixing_energy is True, pd should be a # GrandPotentialPhaseDiagram object and pd_non_grand should be given. if include_no_mixing_energy and not self.grand: raise ValueError('Please provide grand phase diagram to compute' ' no_mixing_energy!') if include_no_mixing_energy and not pd_non_grand: raise ValueError('Please provide non-grand phase diagram to ' 'compute no_mixing_energy!') if self.grand and use_hull_energy and not pd_non_grand: raise ValueError('Please provide non-grand phase diagram if' ' you want to use convex hull energy.') # Keeps copy of original compositions. self.c1_original = c1 self.c2_original = c2 # Two sets of composition attributes for two processing conditions: # normalization with and without exluding element(s) from reservoir. self.c1 = c1 self.c2 = c2 self.comp1 = c1 self.comp2 = c2 self.norm = norm self.pd = pd self.pd_non_grand = pd_non_grand self.use_hull_energy = use_hull_energy # Factor is the compositional ratio between composition self.c1 and # processed composition self.comp1. E.g., factor for # Composition('SiO2') and Composition('O') is 2.0. This factor will # be used to convert mixing ratio in self.comp1 - self.comp2 # tie line to that in self.c1 - self.c2 tie line. self.factor1 = 1 self.factor2 = 1 if self.grand: # Excludes element(s) from reservoir. self.comp1 = Composition({k: v for k, v in c1.items() if k not in pd.chempots}) self.comp2 = Composition({k: v for k, v in c2.items() if k not in pd.chempots}) # Calculate the factors in case where self.grand = True and # self.norm = True. factor1 = self.comp1.num_atoms / c1.num_atoms factor2 = self.comp2.num_atoms / c2.num_atoms if self.norm: self.c1 = c1.fractional_composition self.c2 = c2.fractional_composition self.comp1 = self.comp1.fractional_composition self.comp2 = self.comp2.fractional_composition if self.grand: # Only when self.grand = True and self.norm = True # will self.factor be updated. self.factor1 = factor1 self.factor2 = factor2 # Computes energies for reactants in different scenarios. if not self.grand: if self.use_hull_energy: self.e1 = self.pd.get_hull_energy(self.comp1) self.e2 = self.pd.get_hull_energy(self.comp2) else: # Use entry energy as reactant energy if no reservoir # is present. self.e1 = InterfacialReactivity._get_entry_energy( self.pd, self.comp1) self.e2 = InterfacialReactivity._get_entry_energy( self.pd, self.comp2) else: if include_no_mixing_energy: # Computing grand potentials needs compositions containing # element(s) from reservoir, so self.c1 and self.c2 are used. self.e1 = self._get_grand_potential(self.c1) self.e2 = self._get_grand_potential(self.c2) else: self.e1 = self.pd.get_hull_energy(self.comp1) self.e2 = self.pd.get_hull_energy(self.comp2) @staticmethod def _get_entry_energy(pd, composition): """ Finds the lowest entry energy for entries matching the composition. Entries with non-negative formation energies are excluded. If no entry is found, use the convex hull energy for the composition. Args: pd (PhaseDiagram): PhaseDiagram object. composition (Composition): Composition object that the target entry should match. Returns: The lowest entry energy among entries matching the composition. """ candidate = [i.energy_per_atom for i in pd.qhull_entries if i.composition.fractional_composition == composition.fractional_composition] if not candidate: warnings.warn("The reactant " + composition.reduced_formula + " has no matching entry with negative formation" " energy, instead convex hull energy for this" " composition will be used for reaction energy " "calculation. ") return pd.get_hull_energy(composition) else: min_entry_energy = min(candidate) return min_entry_energy * composition.num_atoms def _get_grand_potential(self, composition): """ Computes the grand potential Phi at a given composition and chemical potential(s). Args: composition (Composition): Composition object. Returns: Grand potential at a given composition at chemical potential(s). """ if self.use_hull_energy: grand_potential = self.pd_non_grand.get_hull_energy(composition) else: grand_potential = InterfacialReactivity._get_entry_energy( self.pd_non_grand, composition) grand_potential -= sum([composition[e] * mu for e, mu in self.pd.chempots.items()]) if self.norm: # Normalizes energy to the composition excluding element(s) # from reservoir. grand_potential /= sum([composition[el] for el in composition if el not in self.pd.chempots]) return grand_potential def _get_energy(self, x): """ Computes reaction energy in eV/atom at mixing ratio x : (1-x) for self.comp1 : self.comp2. Args: x (float): Mixing ratio x of reactants, a float between 0 and 1. Returns: Reaction energy. """ return self.pd.get_hull_energy(self.comp1 * x + self.comp2 * (1 - x)) - self.e1 * x - self.e2 * (1 - x) def _get_reaction(self, x): """ Generates balanced reaction at mixing ratio x : (1-x) for self.comp1 : self.comp2. Args: x (float): Mixing ratio x of reactants, a float between 0 and 1. Returns: Reaction object. """ mix_comp = self.comp1 * x + self.comp2 * (1 - x) decomp = self.pd.get_decomposition(mix_comp) # Uses original composition for reactants. if np.isclose(x, 0): reactant = [self.c2_original] elif np.isclose(x, 1): reactant = [self.c1_original] else: reactant = list(set([self.c1_original, self.c2_original])) if self.grand: reactant += [Composition(e.symbol) for e, v in self.pd.chempots.items()] product = [Composition(k.name) for k, v in decomp.items()] reaction = Reaction(reactant, product) x_original = self._get_original_composition_ratio(reaction) if np.isclose(x_original, 1): reaction.normalize_to(self.c1_original, x_original) else: reaction.normalize_to(self.c2_original, 1 - x_original) return reaction def _get_elmt_amt_in_rxt(self, rxt): """ Computes total number of atoms in a reaction formula for elements not in external reservoir. This method is used in the calculation of reaction energy per mol of reaction formula. Args: rxt (Reaction): a reaction. Returns: Total number of atoms for non_reservoir elements. """ return sum([rxt.get_el_amount(e) for e in self.pd.elements])
[docs] def get_products(self): """ List of formulas of potential products. E.g., ['Li','O2','Mn']. """ products = set() for _, _, _, react, _ in self.get_kinks(): products = products.union(set([k.reduced_formula for k in react.products])) return list(products)
@staticmethod def _convert(x, factor1, factor2): """ Converts mixing ratio x in comp1 - comp2 tie line to that in c1 - c2 tie line. Args: x (float): Mixing ratio x in comp1 - comp2 tie line, a float between 0 and 1. factor1 (float): Compositional ratio between composition c1 and processed composition comp1. E.g., factor for Composition('SiO2') and Composition('O') is 2.0. factor2 (float): Compositional ratio between composition c2 and processed composition comp2. Returns: Mixing ratio in c1 - c2 tie line, a float between 0 and 1. """ return x * factor2 / ((1 - x) * factor1 + x * factor2) @staticmethod def _reverse_convert(x, factor1, factor2): """ Converts mixing ratio x in c1 - c2 tie line to that in comp1 - comp2 tie line. Args: x (float): Mixing ratio x in c1 - c2 tie line, a float between 0 and 1. factor1 (float): Compositional ratio between composition c1 and processed composition comp1. E.g., factor for Composition('SiO2') and Composition('O') is 2. factor2 (float): Compositional ratio between composition c2 and processed composition comp2. Returns: Mixing ratio in comp1 - comp2 tie line, a float between 0 and 1. """ return x * factor1 / ((1 - x) * factor2 + x * factor1)
[docs] def get_kinks(self): """ Finds all the kinks in mixing ratio where reaction products changes along the tie line of composition self.c1 and composition self.c2. Returns: Zip object of tuples (index, mixing ratio, reaction energy per atom in eV/atom, reaction formula, reaction energy per mol of reaction formula in kJ/mol). """ c1_coord = self.pd.pd_coords(self.comp1) c2_coord = self.pd.pd_coords(self.comp2) n1 = self.comp1.num_atoms n2 = self.comp2.num_atoms critical_comp = self.pd.get_critical_compositions(self.comp1, self.comp2) x_kink, energy_kink, react_kink, energy_per_rxt_formula = \ [], [], [], [] if all(c1_coord == c2_coord): x_kink = [0, 1] energy_kink = [self._get_energy(x) for x in x_kink] react_kink = [self._get_reaction(x) for x in x_kink] num_atoms = [(x * self.comp1.num_atoms + (1 - x) * self.comp2.num_atoms) for x in x_kink] energy_per_rxt_formula = [energy_kink[i] * self._get_elmt_amt_in_rxt( react_kink[i]) / num_atoms[i] * InterfacialReactivity.EV_TO_KJ_PER_MOL for i in range(2)] else: for i in reversed(critical_comp): # Gets mixing ratio x at kinks. c = self.pd.pd_coords(i) x = np.linalg.norm(c - c2_coord) / np.linalg.norm(c1_coord - c2_coord) # Modifies mixing ratio in case compositions self.comp1 and # self.comp2 are not normalized. x = x * n2 / (n1 + x * (n2 - n1)) n_atoms = x * self.comp1.num_atoms + (1 - x) * self.comp2.num_atoms # Converts mixing ratio in comp1 - comp2 tie line to that in # c1 - c2 tie line. x_converted = InterfacialReactivity._convert( x, self.factor1, self.factor2) x_kink.append(x_converted) # Gets reaction energy at kinks normalized_energy = self._get_energy(x) energy_kink.append(normalized_energy) # Gets balanced reaction at kinks rxt = self._get_reaction(x) react_kink.append(rxt) rxt_energy = normalized_energy * self._get_elmt_amt_in_rxt(rxt) / n_atoms energy_per_rxt_formula.append( rxt_energy * InterfacialReactivity.EV_TO_KJ_PER_MOL) index_kink = range(1, len(critical_comp) + 1) return zip(index_kink, x_kink, energy_kink, react_kink, energy_per_rxt_formula)
[docs] def get_critical_original_kink_ratio(self): """ Returns a list of molar mixing ratio for each kink between ORIGINAL (instead of processed) reactant compositions. This is the same list as mixing ratio obtained from get_kinks method if self.norm = False. Returns: A list of floats representing molar mixing ratios between the original reactant compositions for each kink. """ ratios = [] if self.c1_original == self.c2_original: return [0, 1] reaction_kink = [k[3] for k in self.get_kinks()] for rxt in reaction_kink: ratios.append(abs(self._get_original_composition_ratio(rxt))) return ratios
def _get_original_composition_ratio(self, reaction): """ Returns the molar mixing ratio between the reactants with ORIGINAL ( instead of processed) compositions for a reaction. Args: reaction (Reaction): Reaction object that contains the original reactant compositions. Returns: The molar mixing ratio between the original reactant compositions for a reaction. """ if self.c1_original == self.c2_original: return 1 c1_coeff = reaction.get_coeff(self.c1_original) \ if self.c1_original in reaction.reactants else 0 c2_coeff = reaction.get_coeff(self.c2_original) \ if self.c2_original in reaction.reactants else 0 return c1_coeff * 1.0 / (c1_coeff + c2_coeff)
[docs] def labels(self): """ Returns a dictionary containing kink information: {index: 'x= mixing_ratio energy= reaction_energy reaction_equation'}. E.g., {1: 'x= 0.0 energy = 0.0 Mn -> Mn', 2: 'x= 0.5 energy = -15.0 O2 + Mn -> MnO2', 3: 'x= 1.0 energy = 0.0 O2 -> O2'}. """ return {j: 'x= ' + str(round(x, 4)) + ' energy in eV/atom = ' + str(round(energy, 4)) + ' ' + str(reaction) for j, x, energy, reaction, _ in self.get_kinks()}
[docs] def plot(self): """ Plots reaction energy as a function of mixing ratio x in self.c1 - self.c2 tie line using pylab. Returns: Pylab object that plots reaction energy as a function of mixing ratio x. """ plt.rcParams['xtick.major.pad'] = '6' plt.rcParams['ytick.major.pad'] = '6' plt.rcParams['axes.linewidth'] = 2 npoint = 1000 xs = np.linspace(0, 1, npoint) # Converts sampling points in self.c1 - self.c2 tie line to those in # self.comp1 - self.comp2 tie line. xs_reverse_converted = InterfacialReactivity._reverse_convert( xs, self.factor1, self.factor2) energies = [self._get_energy(x) for x in xs_reverse_converted] plt.plot(xs, energies, 'k-') # Marks kinks and minimum energy point. kinks = self.get_kinks() _, x_kink, energy_kink, _, _ = zip(*kinks) plt.scatter(x_kink, energy_kink, marker='o', c='blue', s=20) plt.scatter(self.minimum()[0], self.minimum()[1], marker='*', c='red', s=300) # Labels kinks with indices. Labels are made draggable # in case of overlapping. for index, x, energy, _, _ in kinks: plt.annotate( index, xy=(x, energy), xytext=(5, 30), textcoords='offset points', ha='right', va='bottom', arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0')).draggable() plt.xlim([-0.05, 1.05]) if self.norm: plt.ylabel('Energy (eV/atom)') else: plt.ylabel('Energy (eV/f.u.)') plt.xlabel('$x$ in $x$ {} + $(1-x)$ {}'.format( self.c1.reduced_formula, self.c2.reduced_formula)) return plt
[docs] def minimum(self): """ Finds the minimum reaction energy E_min and corresponding mixing ratio x_min. Returns: Tuple (x_min, E_min). """ return min([(x, energy) for _, x, energy, _, _ in self.get_kinks()], key=lambda i: i[1])
[docs] def get_no_mixing_energy(self): """ Generates the opposite number of energy above grand potential convex hull for both reactants. Returns: [(reactant1, no_mixing_energy1),(reactant2,no_mixing_energy2)]. """ assert self.grand == 1, 'Please provide grand potential phase diagram for computing no_mixing_energy!' energy1 = self.pd.get_hull_energy(self.comp1) - self._get_grand_potential(self.c1) energy2 = self.pd.get_hull_energy(self.comp2) - self._get_grand_potential(self.c2) unit = 'eV/f.u.' if self.norm: unit = 'eV/atom' return [(self.c1_original.reduced_formula + ' ({0})'.format(unit), energy1), (self.c2_original.reduced_formula + ' ({0})'.format(unit), energy2)]
[docs] @staticmethod def get_chempot_correction(element, temp, pres): """ Get the normalized correction term Δμ for chemical potential of a gas phase consisting of element at given temperature and pressure, referenced to that in the standard state (T_std = 298.15 K, T_std = 1 bar). The gas phase is limited to be one of O2, N2, Cl2, F2, H2. Calculation formula can be found in the documentation of Materials Project website. Args: element (string): The string representing the element. temp (float): The temperature of the gas phase. pres (float): The pressure of the gas phase. Returns: The correction of chemical potential in eV/atom of the gas phase at given temperature and pressure. """ if element not in ["O", "N", "Cl", "F", "H"]: return 0 std_temp = 298.15 std_pres = 1E5 ideal_gas_const = 8.3144598 # Cp and S at standard state in J/(K.mol). Data from # https://janaf.nist.gov/tables/O-029.html # https://janaf.nist.gov/tables/N-023.html # https://janaf.nist.gov/tables/Cl-073.html # https://janaf.nist.gov/tables/F-054.html # https://janaf.nist.gov/tables/H-050.html Cp_dict = {"O": 29.376, "N": 29.124, "Cl": 33.949, "F": 31.302, "H": 28.836} S_dict = {"O": 205.147, "N": 191.609, "Cl": 223.079, "F": 202.789, "H": 130.680} Cp_std = Cp_dict[element] S_std = S_dict[element] PV_correction = ideal_gas_const * temp * np.log(pres / std_pres) TS_correction = - Cp_std * (temp * np.log(temp) - std_temp * np.log(std_temp)) \ + Cp_std * (temp - std_temp) * (1 + np.log(std_temp)) \ - S_std * (temp - std_temp) dG = PV_correction + TS_correction # Convert to eV/molecule unit. dG /= 1000 * InterfacialReactivity.EV_TO_KJ_PER_MOL # Normalize by number of atoms in the gas molecule. For elements # considered, the gas molecules are all diatomic. dG /= 2 return dG