# fmt: off
"""Modules for calculating thermochemical information from computational
outputs."""
import os
import sys
from abc import ABC, abstractmethod
from typing import Dict, Literal, Optional, Sequence, Tuple, Union
from warnings import catch_warnings, simplefilter, warn
import numpy as np
from scipy.integrate import trapezoid
from ase import Atoms, units
_GEOMETRY_OPTIONS = Literal['linear', 'nonlinear', 'monatomic']
_VIB_SELECTION_OPTIONS = Literal['exact', 'all', 'highest', 'abs_highest']
_FLOAT_OR_FLOATWITHDICT = Union[float, Tuple[float, Dict[str, float]]]
_FLOATWITHDICT = Tuple[float, Dict[str, float]]
def _sum_contributions(contrib_dicts: Dict[str, float]) -> float:
"""Combine a Dict of floats to their sum.
Ommits keys starting with an underscore.
"""
return np.sum([value for key, value in contrib_dicts.items()
if not key.startswith('_')])
[docs]
class AbstractMode(ABC):
"""Abstract base class for mode objects.
Input:
energy: float
Initialize the mode with its energy in eV.
Note: This energy refers to the vibrational energy of the mode.
Get it e.g. from ase.vibrations.Vibrations.get_energies.
"""
def __init__(self, energy: float) -> None:
self.energy = energy
@abstractmethod
def get_internal_energy(
self,
temperature: float,
contributions: bool) -> _FLOAT_OR_FLOATWITHDICT:
raise NotImplementedError
@abstractmethod
def get_entropy(self, temperature: float,
contributions: bool) -> _FLOAT_OR_FLOATWITHDICT:
raise NotImplementedError
[docs]
class HarmonicMode(AbstractMode):
"""Class for a single harmonic mode."""
[docs]
def get_ZPE_correction(self) -> float:
"""Returns the zero-point vibrational energy correction in eV."""
return 0.5 * self.energy
[docs]
def get_vib_energy_contribution(self, temperature: float) -> float:
"""Calculates the change in internal energy due to vibrations from
0K to the specified temperature for a set of vibrations given in
eV and a temperature given in Kelvin.
Returns the energy change in eV."""
kT = units.kB * temperature
return self.energy / (np.exp(self.energy / kT) - 1.)
[docs]
def get_vib_entropy_contribution(self, temperature: float) -> float:
"""Calculates the entropy due to vibrations given in eV and a
temperature given in Kelvin. Returns the entropy in eV/K."""
e = self.energy / units.kB / temperature
S_v = e / (np.exp(e) - 1.) - np.log(1. - np.exp(-e))
S_v *= units.kB
return S_v
[docs]
def get_internal_energy(self,
temperature: float,
contributions: bool
) -> _FLOAT_OR_FLOATWITHDICT:
"""Returns the internal energy in the harmonic approximation at a
specified temperature (K)."""
ret = {}
ret['ZPE'] = self.get_ZPE_correction()
ret['dU_v'] = self.get_vib_energy_contribution(temperature)
ret_sum = _sum_contributions(ret)
if contributions:
return ret_sum, ret
else:
return ret_sum
[docs]
def get_entropy(self,
temperature: float,
contributions: bool) -> _FLOAT_OR_FLOATWITHDICT:
"""Returns the entropy in the harmonic approximation at a specified
temperature (K).
Parameters
----------
temperature : float
The temperature in Kelvin.
contributions : bool
If True, return the contributions to the entropy as a dict too.
Here it will only contain the vibrational entropy.
Returns
-------
float or Tuple[float, Dict[str, float]]
"""
ret = {}
ret['S_v'] = self.get_vib_entropy_contribution(temperature)
if contributions:
return ret['S_v'], ret
else:
return ret['S_v']
[docs]
class RRHOMode(HarmonicMode):
"""Class for a single RRHO mode, including Grimme's scaling method
based on :doi:`10.1002/chem.201200497` and :doi:`10.1039/D1SC00621E`.
Inputs:
mean_inertia : float
The mean moment of inertia in amu*angstrom^2. Use
`np.mean(ase.Atoms.get_moments_of_inertia())` to calculate.
tau : float
the vibrational energy threshold in :math:`cm^{-1}`, named
:math:`\\tau` in :doi:`10.1039/D1SC00621E`.
Values close or equal to 0 will result in the standard harmonic
approximation. Defaults to :math:`35cm^{-1}`.
treat_int_energy : bool
Extend the msRRHO treatement to the internal thermal energy.
If False, only the entropy contribution as in Grimmmes paper is
modified according to the RRHO scheme. If True, the approach of
Otlyotov and Minenkov :doi:`10.1002/jcc.27129` is used to modify
the internal energy contribution.
"""
def __init__(self, energy: float,
mean_inertia: float,
tau: float = 35.0,
treat_int_energy: bool = False) -> None:
if np.iscomplex(energy):
raise ValueError(
"Imaginary frequencies are not allowed in RRHO mode.")
super().__init__(energy)
self._mean_inertia = mean_inertia
self._tau = tau
self._alpha = 4 # from paper 10.1002/chem.201200497
self.treat_int_energy = treat_int_energy
@property
def frequency(self) -> float:
return self.energy / units.invcm
def _head_gordon_damp(self, freq: float) -> float:
"""Head-Gordon damping function.
Equation 8 from :doi:`10.1002/chem.201200497`
Parameters
----------
freq : float
The frequency in the same unit as tau.
Returns
-------
float
"""
return 1 / (1 + (self._tau / freq)**self._alpha)
def _apply_head_gordon_damp(self, freq: float,
large_part: float,
small_part: float) -> float:
"""Apply the head-gordon damping scheme to two contributions.
Equation 7 from :doi:`10.1002/chem.201200497`
Returns the damped sum of the two contributions."""
part_one = self._head_gordon_damp(freq) * large_part
part_two = (1 - self._head_gordon_damp(freq)) * small_part
return part_one + part_two
[docs]
def get_RRHO_entropy_r(self, temperature: float) -> float:
"""Calculates the rotation of a rigid rotor for low frequency modes.
Equation numbering from :doi:`10.1002/chem.201200497`
Returns the entropy contribution in eV/K."""
kT = units._k * temperature
R = units._k * units._Nav # J / K / mol
B_av = (self._mean_inertia /
(units.kg * units.m**2)) # from amu/A^2 to kg m^2
# note, some codes use B_av = 1e-44 as in 10.1002/chem.201200497
# eq 4
omega = units._c * self.frequency * 1e2 # s^-1
mu = units._hplanck / (8 * np.pi**2 * omega) # kg m^2
# eq 5
mu_prime = (mu * B_av) / (mu + B_av) # kg m^2
# eq 6
x = np.sqrt(8 * np.pi**3 * mu_prime * kT / (units._hplanck)**2)
# filter zeros out and set them to zero
if x == 0.0:
log_x = 0.0
else:
log_x = np.log(x)
S_r = R * (0.5 + log_x) # J/(Js)^2
S_r *= units.J / units._Nav # J/K/mol to eV/K
return S_r
[docs]
def get_entropy(self,
temperature: float,
contributions: bool) -> _FLOAT_OR_FLOATWITHDICT:
ret = {}
ret['_S_vib_v'] = self.get_vib_entropy_contribution(temperature)
ret['_S_vib_r'] = self.get_RRHO_entropy_r(temperature)
ret['S_vib_damped'] = self._apply_head_gordon_damp(
self.frequency, ret['_S_vib_v'], ret['_S_vib_r'])
if contributions:
return ret['S_vib_damped'], ret
else:
return ret['S_vib_damped']
[docs]
def get_rrho_internal_energy_v_contribution(
self, temperature: float) -> float:
"""RRHO Vibrational Internal Energy Contribution from
:doi:`10.1002/jcc.27129`.
Returns the internal energy contribution in eV."""
# equation numbering from :doi:`10.1002/jcc.27129`
# eq 4
# hv = self.energy
theta = self.energy / units.kB
E_v = 0.5 + 1 / (np.exp(theta / temperature) - 1)
E_v *= self.energy # = theta * units.kB
return E_v
[docs]
@staticmethod
def get_rrho_internal_energy_r_contribution(temperature: float) -> float:
"""Calculates the rotation of a rigid rotor contribution.
Equation numbering from :doi:`10.1002/jcc.27129`
Returns the internal energy contribution in eV."""
# eq 5
R = units._k * units._Nav
E_r = R * temperature / 2
E_r *= units.J / units._Nav
return E_r
[docs]
def get_internal_energy(self,
temperature: float,
contributions: bool) -> _FLOAT_OR_FLOATWITHDICT:
"""Returns the internal energy in the msRRHO approximation at a
specified temperature (K).
If self.treat_int_energy is True, the approach of Otlyotov
and Minenkov :doi:`10.1002/jcc.27129` is used. Otherwise, the approach
of Grimme :doi:`10.1002/chem.201200497` is used.
"""
if self.treat_int_energy:
# Otlyotov and Minenkov approach with damping between vibrational
# and rotational contributions to the internal energy
# Note: The ZPE is not needed here, as the formula in the paper
# uses the "bottom of the well" as reference. See
# https://gaussian.com/wp-content/uploads/dl/thermo.pdf
# for more formulas
ret = {}
ret['_dU_vib_v'] = self.get_rrho_internal_energy_v_contribution(
temperature)
ret['_dU_vib_r'] = self.get_rrho_internal_energy_r_contribution(
temperature)
ret['dU_vib_damped'] = self._apply_head_gordon_damp(
self.frequency, ret['_dU_vib_v'], ret['_dU_vib_r'])
if contributions:
return ret['dU_vib_damped'], ret
else:
return ret['dU_vib_damped']
else:
# Grimme uses the Harmonic approach for the internal energy
return super().get_internal_energy(temperature, contributions)
[docs]
class BaseThermoChem(ABC):
"""Abstract base class containing common methods used in thermochemistry
calculations."""
def __init__(self,
vib_energies: Optional[Sequence[float]] = None,
atoms: Optional[Atoms] = None,
modes: Optional[Sequence[AbstractMode]] = None,
spin: Optional[float] = None) -> None:
if vib_energies is None and modes is None:
raise ValueError("Either vib_energies or modes must be provided.")
elif modes:
self.modes = modes
# in the monoatomic case, the vib_energies can be an empty list
else:
self._vib_energies = vib_energies
self.referencepressure = 1.0e5 # Pa
if atoms:
self.atoms = atoms
self.spin = spin
[docs]
@classmethod
def from_transition_state(cls, vib_energies: Sequence[complex],
*args, **kwargs) -> "BaseThermoChem":
"""Create a new instance for a transition state.
This will work just as the standard constructor, but will remove
one imaginary frequency from the given vib_energies first.
If there is more than one imaginary frequency, an error will be raised.
Returns
-------
BaseThermoChem instance
"""
if sum(np.iscomplex(vib_energies)):
# supress user warning
with catch_warnings():
simplefilter("ignore")
vib_e, n_imag = _clean_vib_energies(vib_energies, True)
if n_imag != 1:
raise ValueError("Not exactly one imaginary frequency found.")
else:
raise ValueError("No imaginary frequencies found in vib_energies.")
thermo = cls(vib_e, *args, **kwargs)
return thermo
[docs]
@staticmethod
def combine_contributions(
contrib_dicts: Sequence[Dict[str, float]]) -> Dict[str, float]:
"""Combine the contributions from multiple modes."""
ret: dict[str, float] = {}
for contrib_dict in contrib_dicts:
for key, value in contrib_dict.items():
if key in ret:
ret[key] += value
else:
ret[key] = value
return ret
[docs]
def print_contributions(
self, contributions: Dict[str, float], verbose: bool) -> None:
"""Print the contributions."""
if verbose:
fmt = "{:<15s}{:13.3f} eV"
for key, value in contributions.items():
# subvalues start with _
if key.startswith('_'):
key.replace('_', '... ')
self._vprint(fmt.format(key, value))
@abstractmethod
def get_internal_energy(self, temperature: float, verbose: bool) -> float:
raise NotImplementedError
@abstractmethod
def get_entropy(self, temperature: float,
pressure: float = units.bar,
verbose: bool = True) -> float:
raise NotImplementedError
@property
def vib_energies(self) -> np.ndarray:
"""Get the vibrational energies in eV.
For backwards compatibility, this will return the modes' energies if
they are available. Otherwise, it will return the stored vib_energies.
"""
# build from modes if available
if hasattr(self, 'modes'):
return np.array([mode.energy for mode in self.modes])
elif hasattr(self, '_vib_energies'):
return np.array(self._vib_energies)
else:
raise AttributeError("No vibrational energies available.")
@vib_energies.setter
def vib_energies(self, value: Sequence[float]) -> None:
"""For backwards compatibility, raise a deprecation warning."""
warn(
"The vib_energies attribute is deprecated and will be removed in a"
"future release. Please use the modes attribute instead."
"Setting this outside the constructor will not update the modes",
DeprecationWarning)
self._vib_energies = value
[docs]
def get_ZPE_correction(self) -> float:
"""Returns the zero-point vibrational energy correction in eV."""
return 0.5 * np.sum(self.vib_energies)
[docs]
@staticmethod
def get_ideal_translational_energy(temperature: float) -> float:
"""Returns the translational heat capacity times T in eV.
Parameters
----------
temperature : float
The temperature in Kelvin.
Returns
-------
float
"""
return 3. / 2. * units.kB * \
temperature # translational heat capacity (3-d gas)
[docs]
@staticmethod
def get_ideal_rotational_energy(geometry: _GEOMETRY_OPTIONS,
temperature: float) -> float:
"""Returns the rotational heat capacity times T in eV.
Parameters
----------
geometry : str
The geometry of the molecule. Options are 'nonlinear',
'linear', and 'monatomic'.
temperature : float
The temperature in Kelvin.
Returns
-------
float
"""
if geometry == 'nonlinear': # rotational heat capacity
Cv_r = 3. / 2. * units.kB
elif geometry == 'linear':
Cv_r = units.kB
elif geometry == 'monatomic':
Cv_r = 0.
else:
raise ValueError('Invalid geometry: %s' % geometry)
return Cv_r * temperature
[docs]
def get_ideal_trans_entropy(
self,
atoms: Atoms,
temperature: float) -> float:
"""Returns the translational entropy in eV/K.
Parameters
----------
atoms : ase.Atoms
The atoms object.
temperature : float
The temperature in Kelvin.
Returns
-------
float
"""
# Translational entropy (term inside the log is in SI units).
mass = sum(atoms.get_masses()) * units._amu # kg/molecule
S_t = (2 * np.pi * mass * units._k *
temperature / units._hplanck**2)**(3.0 / 2)
S_t *= units._k * temperature / self.referencepressure
S_t = units.kB * (np.log(S_t) + 5.0 / 2.0)
return S_t
[docs]
def get_vib_energy_contribution(self, temperature: float) -> float:
"""Calculates the change in internal energy due to vibrations from
0K to the specified temperature for a set of vibrations given in
eV and a temperature given in Kelvin.
Returns the energy change in eV."""
# Leftover for HinderedThermo class, delete once that is converted
# to use modes
kT = units.kB * temperature
dU = 0.
for energy in self.vib_energies:
dU += energy / (np.exp(energy / kT) - 1.)
return dU
[docs]
def get_vib_entropy_contribution(self,
temperature: float,
return_list: bool = False
) -> Union[float, Sequence[float]]:
"""Calculates the entropy due to vibrations for a set of vibrations
given in eV and a temperature given in Kelvin. Returns the entropy
in eV/K."""
kT = units.kB * temperature
S_v = 0.
energies = np.array(self.vib_energies)
energies /= kT # eV/ eV/K*K
S_v = energies / (np.exp(energies) - 1.) - \
np.log(1. - np.exp(-energies))
S_v *= units.kB
if return_list:
return S_v
else:
return np.sum(S_v)
def _vprint(self, text):
"""Print output if verbose flag True."""
if self.verbose:
sys.stdout.write(text + os.linesep)
[docs]
def get_ideal_entropy(
self,
temperature: float,
translation: bool = False,
vibration: bool = False,
rotation: bool = False,
geometry: Optional[_GEOMETRY_OPTIONS] = None,
electronic: bool = False,
pressure: Optional[float] = None,
symmetrynumber: Optional[int] = None) -> _FLOATWITHDICT:
"""Returns the entropy, in eV/K and a dict of the contributions.
Parameters
----------
temperature : float
The temperature in Kelvin.
translation : bool
Include translational entropy.
vibration : bool
Include vibrational entropy.
rotation : bool
Include rotational entropy.
geometry : str
The geometry of the molecule. Options are 'nonlinear',
'linear', and 'monatomic'.
electronic : bool
Include electronic entropy.
pressure : float
The pressure in Pa. Only needed for the translational entropy.
symmetrynumber : int
The symmetry number of the molecule. Only needed for linear and
nonlinear molecules.
Returns
-------
Tuple of one float and one dict
The float is the total entropy in eV/K.
The dict contains the contributions to the entropy.
"""
if (geometry in ['linear', 'nonlinear']) and (symmetrynumber is None):
raise ValueError(
'Symmetry number required for linear and nonlinear molecules.')
if not hasattr(self, 'atoms'):
raise ValueError(
'Atoms object required for ideal entropy calculation.')
if electronic and (self.spin is None):
raise ValueError(
'Spin value required for electronic entropy calculation.')
S: float = 0.0
ret = {}
if translation:
S_t = self.get_ideal_trans_entropy(self.atoms, temperature)
ret['S_t'] = S_t
S += S_t
if pressure:
# Pressure correction to translational entropy.
S_p = - units.kB * np.log(pressure / self.referencepressure)
S += S_p
ret['S_p'] = S_p
if rotation:
# Rotational entropy (term inside the log is in SI units).
if geometry == 'monatomic':
S_r = 0.0
elif geometry == 'nonlinear':
inertias = (self.atoms.get_moments_of_inertia() * units._amu /
1e10**2) # kg m^2
S_r = np.sqrt(np.pi * np.prod(inertias)) / symmetrynumber
S_r *= (8.0 * np.pi**2 * units._k * temperature /
units._hplanck**2)**(3.0 / 2.0)
S_r = units.kB * (np.log(S_r) + 3.0 / 2.0)
elif geometry == 'linear':
inertias = (self.atoms.get_moments_of_inertia() * units._amu /
(10.0**10)**2) # kg m^2
inertia = max(inertias) # should be two identical and one zero
S_r = (8 * np.pi**2 * inertia * units._k * temperature /
symmetrynumber / units._hplanck**2)
S_r = units.kB * (np.log(S_r) + 1.)
else:
raise RuntimeError(f"Invalid geometry: {geometry}")
S += S_r
ret['S_r'] = S_r
# Electronic entropy.
if electronic:
assert self.spin is not None # for mypy, error is raised above
S_e = units.kB * np.log(2 * self.spin + 1)
S += S_e
ret['S_e'] = S_e
# Vibrational entropy
if vibration:
S_v = self.get_vib_entropy_contribution(temperature,
return_list=False)
assert isinstance(S_v, float) # make mypy happy
S += S_v
ret['S_v'] = S_v
return S, ret
[docs]
class HarmonicThermo(BaseThermoChem):
"""Class for calculating thermodynamic properties in the approximation
that all degrees of freedom are treated harmonically. Often used for
adsorbates.
Note: This class does not include the translational and rotational
contributions to the entropy by default. Use the get_ideal_entropy method
for that and add them manually.
Inputs:
vib_energies : list
a list of the harmonic energies of the adsorbate (e.g., from
ase.vibrations.Vibrations.get_energies). The number of
energies should match the number of degrees of freedom of the
adsorbate; i.e., 3*n, where n is the number of atoms. Note that
this class does not check that the user has supplied the correct
number of energies. Units of energies are eV. Note, that if 'modes'
are given, these energies are not used.
potentialenergy : float
the potential energy in eV (e.g., from atoms.get_potential_energy)
(if potentialenergy is unspecified, then the methods of this
class can be interpreted as the energy corrections)
ignore_imag_modes : bool
If 'True', any imaginary frequencies will be removed in the
calculation of the thermochemical properties.
If 'False' (default), an error will be raised if any imaginary
frequencies are present.
modes : list of AbstractMode
A list of mode objects. If not provided, :class:`HarmonicMode` objects
will be created from the vib_energies. This is useful if you want to
replace individual modes with non-harmonic modes.
"""
def __init__(self, vib_energies: Sequence[complex],
potentialenergy: float = 0.,
ignore_imag_modes: bool = False,
modes: Optional[Sequence[AbstractMode]] = None) -> None:
# Check for imaginary frequencies.
vib_energies, n_imag = _clean_vib_energies(
vib_energies, ignore_imag_modes)
if modes is None:
modes = [HarmonicMode(energy) for energy in vib_energies]
super().__init__(modes=modes)
self.n_imag = n_imag
self.potentialenergy = potentialenergy
[docs]
def get_internal_energy(self, temperature: float,
verbose: bool = True) -> float:
"""Returns the internal energy, in eV, in the harmonic approximation
at a specified temperature (K)."""
self.verbose = verbose
vprint = self._vprint
fmt = '%-15s%13.3f eV'
vprint('Internal energy components at T = %.2f K:' % temperature)
vprint('=' * 31)
vprint(fmt % ('E_pot', self.potentialenergy))
U, contribs = zip(*[mode.get_internal_energy(
temperature, contributions=True) for mode in self.modes])
U = np.sum(U)
U += self.potentialenergy
self.print_contributions(self.combine_contributions(contribs), verbose)
vprint('-' * 31)
vprint(fmt % ('U', U))
vprint('=' * 31)
return U
[docs]
def get_entropy(self, temperature: float,
pressure: float = units.bar,
verbose: bool = True) -> float:
"""Returns the entropy, in eV/K at a specified temperature (K).
Note: This does not include the translational and rotational
contributions to the entropy. Use the get_ideal_entropy method
for that.
Parameters
----------
temperature : float
The temperature in Kelvin.
pressure : float
Not used, but kept for compatibility with other classes.
verbose : bool
If True, print the contributions to the entropy.
Returns
-------
float
"""
self.verbose = verbose
vprint = self._vprint
fmt = '%-15s%13.7f eV/K%13.3f eV'
vprint('Entropy components at T = %.2f K:' % temperature)
vprint('=' * 49)
vprint('%15s%13s %13s' % ('', 'S', 'T*S'))
S, contribs = zip(*[mode.get_entropy(
temperature, contributions=True) for mode in self.modes])
S = np.sum(S)
self.print_contributions(self.combine_contributions(contribs), verbose)
vprint('-' * 49)
vprint(fmt % ('S', S, S * temperature))
vprint('=' * 49)
return S
[docs]
def get_helmholtz_energy(self, temperature: float,
verbose: bool = True) -> float:
"""Returns the Helmholtz free energy, in eV, in the harmonic
approximation at a specified temperature (K)."""
self.verbose = True
vprint = self._vprint
U = self.get_internal_energy(temperature, verbose=verbose)
vprint('')
S = self.get_entropy(temperature, verbose=verbose)
F = U - temperature * S
vprint('')
vprint('Free energy components at T = %.2f K:' % temperature)
vprint('=' * 23)
fmt = '%5s%15.3f eV'
vprint(fmt % ('U', U))
vprint(fmt % ('-T*S', -temperature * S))
vprint('-' * 23)
vprint(fmt % ('F', F))
vprint('=' * 23)
return F
[docs]
class QuasiHarmonicThermo(HarmonicThermo):
"""Subclass of :class:`HarmonicThermo`, including the quasi-harmonic
approximation of Cramer, Truhlar and coworkers :doi:`10.1021/jp205508z`.
Note: This class not include the translational and rotational
contributions to the entropy by default. Use the get_ideal_entropy method
for that and add them manually.
Inputs:
vib_energies : list
a list of the energies of the vibrations (e.g., from
ase.vibrations.Vibrations.get_energies). The number of
energies should match the number of degrees of freedom of the
adsorbate; i.e., 3*n, where n is the number of atoms. Note that
this class does not check that the user has supplied the correct
number of energies. Units of energies are eV.
potentialenergy : float
the potential energy in eV (e.g., from atoms.get_potential_energy)
(if potentialenergy is unspecified, then the methods of this
class can be interpreted as the energy corrections)
ignore_imag_modes : bool
If 'True', any imaginary frequencies will be removed in the
calculation of the thermochemical properties.
If 'False' (default), an error will be raised if any imaginary
frequencies are present.
modes : list of AbstractMode
A list of mode objects. If not provided, :class:`HarmonicMode` objects
will be created from the raised vib_energies. This is useful if you want
to replace individual modes with non-harmonic modes.
raise_to : float
The value to which all frequencies smaller than this value will be
raised. Unit is eV. Defaults to :math:`100cm^{-1} = 0.012398 eV`.
"""
@staticmethod
def _raise(input: Sequence[float], raise_to: float) -> Sequence[float]:
return [raise_to if x < raise_to else x for x in input]
def __init__(self, vib_energies: Sequence[complex],
potentialenergy: float = 0.,
ignore_imag_modes: bool = False,
modes: Optional[Sequence[AbstractMode]] = None,
raise_to: float = 100 * units.invcm) -> None:
# Check for imaginary frequencies.
vib_energies, _ = _clean_vib_energies(
vib_energies, ignore_imag_modes)
# raise the low frequencies to a certain value
vib_energies = self._raise(vib_energies, raise_to)
if modes is None:
modes = [HarmonicMode(energy) for energy in vib_energies]
super().__init__(vib_energies,
potentialenergy=potentialenergy,
ignore_imag_modes=ignore_imag_modes,
modes=modes)
[docs]
class MSRRHOThermo(QuasiHarmonicThermo):
"""Subclass of :class:`QuasiHarmonicThermo`,
including Grimme's scaling method based on
:doi:`10.1002/chem.201200497` and :doi:`10.1039/D1SC00621E`.
Note: This class not include the translational and rotational
contributions to the entropy by default. Use the get_ideal_entropy method
for that and add them manually.
We enforce treating imaginary modes as Grimme suggests (converting
them to real by multiplying them with :math:`-i`). So make sure to check
your input energies.
Inputs:
vib_energies : list
a list of the energies of the vibrations (e.g., from
ase.vibrations.Vibrations.get_energies). The number of
energies should match the number of degrees of freedom of the
atoms object; i.e., 3*n, where n is the number of atoms. Note that
this class does not check that the user has supplied the correct
number of energies. Units of energies are eV. Note, that if 'modes'
are given, these energies are not used.
atoms: an ASE atoms object
used to calculate rotational moments of inertia and molecular mass
tau : float
the vibrational energy threshold in :math:`cm^{-1}`, namcomplexed
:math:`\\tau` in :doi:`10.1039/D1SC00621E`.
Values close or equal to 0 will result in the standard harmonic
approximation. Defaults to :math:`35cm^{-1}`.
nu_scal : float
Linear scaling factor for the vibrational frequencies. Named
:math:`\\nu_{scal}` in :doi:`10.1039/D1SC00621E`.
Defaults to 1.0, check the `Truhlar group database
<https://comp.chem.umn.edu/freqscale/index.html>`_
for values corresponding to your level of theory.
Note that for `\\nu_{scal}=1.0` this method is equivalent to
the quasi-RRHO method in :doi:`10.1002/chem.201200497`.
treat_int_energy : bool
Extend the msRRHO treatement to the internal energy. If False, only
the entropy contribution as in Grimmmes paper is considered.
If true, the approach of Otlyotov and Minenkov
:doi:`10.1002/jcc.27129` is used.
modes : list of AbstractMode
A list of mode objects. If not provided, :class:`RRHOMode` objects will
be created from the raised vib_energies. This is useful if you want to
replace individual modes with non-harmonic modes.
"""
def __init__(self, vib_energies: Sequence[complex], atoms: Atoms,
potentialenergy: float = 0.,
tau: float = 35., nu_scal: float = 1.0,
treat_int_energy: bool = False,
modes: Optional[Sequence[AbstractMode]] = None) -> None:
inertia = np.mean(atoms.get_moments_of_inertia())
self.atoms = atoms
# clean the energies by converting imaginary to real
# i.e. multiply with -i
vib_e: Sequence[float] # make mypy happy
vib_e = [np.imag(v) if np.iscomplex(v)
else np.real(v) for v in vib_energies]
self.nu_scal = nu_scal
# scale the frequencies (i.e. energies) before passing them on
vib_e_scaled = [float(v) for v in np.multiply(vib_e, nu_scal)]
if modes is None:
modes = [RRHOMode(energy, inertia,
tau=tau,
treat_int_energy=treat_int_energy
) for energy in vib_e_scaled]
super().__init__(vib_e_scaled,
potentialenergy=potentialenergy,
ignore_imag_modes=False,
modes=modes,
raise_to=0.0)
self.treat_int_energy = treat_int_energy
[docs]
class HinderedThermo(BaseThermoChem):
"""Class for calculating thermodynamic properties in the hindered
translator and hindered rotor model where all but three degrees of
freedom are treated as harmonic vibrations, two are treated as
hindered translations, and one is treated as a hindered rotation.
Inputs:
vib_energies : list
a list of all the vibrational energies of the adsorbate (e.g., from
ase.vibrations.Vibrations.get_energies). If atoms is not provided,
then the number of energies must match the number of degrees of freedom
of the adsorbate; i.e., 3*n, where n is the number of atoms. Note
that this class does not check that the user has supplied the
correct number of energies.
Units of energies are eV.
trans_barrier_energy : float
the translational energy barrier in eV. This is the barrier for an
adsorbate to diffuse on the surface.
rot_barrier_energy : float
the rotational energy barrier in eV. This is the barrier for an
adsorbate to rotate about an axis perpendicular to the surface.
sitedensity : float
density of surface sites in cm^-2
rotationalminima : integer
the number of equivalent minima for an adsorbate's full rotation.
For example, 6 for an adsorbate on an fcc(111) top site
potentialenergy : float
the potential energy in eV (e.g., from atoms.get_potential_energy)
(if potentialenergy is unspecified, then the methods of this class
can be interpreted as the energy corrections)
mass : float
the mass of the adsorbate in amu (if mass is unspecified, then it will
be calculated from the atoms class)
inertia : float
the reduced moment of inertia of the adsorbate in amu*Ang^-2
(if inertia is unspecified, then it will be calculated from the
atoms class)
atoms : an ASE atoms object
used to calculate rotational moments of inertia and molecular mass
symmetrynumber : integer
symmetry number of the adsorbate. This is the number of symmetric arms
of the adsorbate and depends upon how it is bound to the surface.
For example, propane bound through its end carbon has a symmetry
number of 1 but propane bound through its middle carbon has a symmetry
number of 2. (if symmetrynumber is unspecified, then the default is 1)
ignore_imag_modes : bool
If 'True', any imaginary frequencies present after the 3N-3 cut will
be removed in the calculation of the thermochemical properties.
If 'False' (default), an error will be raised if imaginary frequencies
are present after the 3N-3 cut.
"""
def __init__(self, vib_energies, trans_barrier_energy, rot_barrier_energy,
sitedensity, rotationalminima, potentialenergy=0.,
mass=None, inertia=None, atoms=None, symmetrynumber=1,
ignore_imag_modes: bool = False) -> None:
self.trans_barrier_energy = trans_barrier_energy * units._e
self.rot_barrier_energy = rot_barrier_energy * units._e
self.area = 1. / sitedensity / 100.0**2
self.rotationalminima = rotationalminima
self.potentialenergy = potentialenergy
self.atoms = atoms
self.symmetry = symmetrynumber
# Sort the vibrations
vib_energies = list(vib_energies)
vib_energies.sort(key=np.abs)
# Keep only the relevant vibrational energies (3N-3)
if atoms:
vib_energies = vib_energies[-(3 * len(atoms) - 3):]
else:
vib_energies = vib_energies[-(len(vib_energies) - 3):]
# Check for imaginary frequencies.
vib_energies, n_imag = _clean_vib_energies(
vib_energies, ignore_imag_modes)
super().__init__(vib_energies=vib_energies)
self.n_imag = n_imag
if (mass or atoms) and (inertia or atoms):
if mass:
self.mass = mass * units._amu
elif atoms:
self.mass = np.sum(atoms.get_masses()) * units._amu
if inertia:
self.inertia = inertia * units._amu / units.m**2
elif atoms:
self.inertia = (atoms.get_moments_of_inertia()[2] *
units._amu / units.m**2)
else:
raise RuntimeError('Either mass and inertia of the '
'adsorbate must be specified or '
'atoms must be specified.')
# Calculate hindered translational and rotational frequencies
self.freq_t = np.sqrt(self.trans_barrier_energy /
(2 * self.mass * self.area))
self.freq_r = 1. / (2 * np.pi) * np.sqrt(self.rotationalminima**2 *
self.rot_barrier_energy /
(2 * self.inertia))
[docs]
def get_internal_energy(self, temperature, verbose=True):
"""Returns the internal energy (including the zero point energy),
in eV, in the hindered translator and hindered rotor model at a
specified temperature (K)."""
from scipy.special import iv
self.verbose = verbose
vprint = self._vprint
fmt = '%-15s%13.3f eV'
vprint('Internal energy components at T = %.2f K:' % temperature)
vprint('=' * 31)
U = 0.
vprint(fmt % ('E_pot', self.potentialenergy))
U += self.potentialenergy
# Translational Energy
T_t = units._k * temperature / (units._hplanck * self.freq_t)
R_t = self.trans_barrier_energy / (units._hplanck * self.freq_t)
dU_t = 2 * (-1. / 2 - 1. / T_t / (2 + 16 * R_t) + R_t / 2 / T_t -
R_t / 2 / T_t *
iv(1, R_t / 2 / T_t) / iv(0, R_t / 2 / T_t) +
1. / T_t / (np.exp(1. / T_t) - 1))
dU_t *= units.kB * temperature
vprint(fmt % ('E_trans', dU_t))
U += dU_t
# Rotational Energy
T_r = units._k * temperature / (units._hplanck * self.freq_r)
R_r = self.rot_barrier_energy / (units._hplanck * self.freq_r)
dU_r = (-1. / 2 - 1. / T_r / (2 + 16 * R_r) + R_r / 2 / T_r -
R_r / 2 / T_r *
iv(1, R_r / 2 / T_r) / iv(0, R_r / 2 / T_r) +
1. / T_r / (np.exp(1. / T_r) - 1))
dU_r *= units.kB * temperature
vprint(fmt % ('E_rot', dU_r))
U += dU_r
# Vibrational Energy
dU_v = self.get_vib_energy_contribution(temperature)
vprint(fmt % ('E_vib', dU_v))
U += dU_v
# Zero Point Energy
dU_zpe = self.get_zero_point_energy()
vprint(fmt % ('E_ZPE', dU_zpe))
U += dU_zpe
vprint('-' * 31)
vprint(fmt % ('U', U))
vprint('=' * 31)
return U
[docs]
def get_zero_point_energy(self, verbose=True):
"""Returns the zero point energy, in eV, in the hindered
translator and hindered rotor model"""
zpe_t = 2 * (1. / 2 * self.freq_t * units._hplanck / units._e)
zpe_r = 1. / 2 * self.freq_r * units._hplanck / units._e
zpe_v = self.get_ZPE_correction()
zpe = zpe_t + zpe_r + zpe_v
return zpe
[docs]
def get_entropy(self, temperature,
pressure=units.bar,
verbose=True):
"""Returns the entropy, in eV/K, in the hindered translator
and hindered rotor model at a specified temperature (K)."""
from scipy.special import iv
self.verbose = verbose
vrpint = self._vprint
fmt = '%-15s%13.7f eV/K%13.3f eV'
vrpint('Entropy components at T = %.2f K:' % temperature)
vrpint('=' * 49)
vrpint('%15s%13s %13s' % ('', 'S', 'T*S'))
S = 0.
# Translational Entropy
T_t = units._k * temperature / (units._hplanck * self.freq_t)
R_t = self.trans_barrier_energy / (units._hplanck * self.freq_t)
S_t = 2 * (-1. / 2 + 1. / 2 * np.log(np.pi * R_t / T_t) -
R_t / 2 / T_t *
iv(1, R_t / 2 / T_t) / iv(0, R_t / 2 / T_t) +
np.log(iv(0, R_t / 2 / T_t)) +
1. / T_t / (np.exp(1. / T_t) - 1) -
np.log(1 - np.exp(-1. / T_t)))
S_t *= units.kB
vrpint(fmt % ('S_trans', S_t, S_t * temperature))
S += S_t
# Rotational Entropy
T_r = units._k * temperature / (units._hplanck * self.freq_r)
R_r = self.rot_barrier_energy / (units._hplanck * self.freq_r)
S_r = (-1. / 2 + 1. / 2 * np.log(np.pi * R_r / T_r) -
np.log(self.symmetry) -
R_r / 2 / T_r * iv(1, R_r / 2 / T_r) / iv(0, R_r / 2 / T_r) +
np.log(iv(0, R_r / 2 / T_r)) +
1. / T_r / (np.exp(1. / T_r) - 1) -
np.log(1 - np.exp(-1. / T_r)))
S_r *= units.kB
vrpint(fmt % ('S_rot', S_r, S_r * temperature))
S += S_r
# Vibrational Entropy
S_v = self.get_vib_entropy_contribution(temperature)
vrpint(fmt % ('S_vib', S_v, S_v * temperature))
S += S_v
# Concentration Related Entropy
N_over_A = np.exp(1. / 3) * (10.0**5 /
(units._k * temperature))**(2. / 3)
S_c = 1 - np.log(N_over_A) - np.log(self.area)
S_c *= units.kB
vrpint(fmt % ('S_con', S_c, S_c * temperature))
S += S_c
vrpint('-' * 49)
vrpint(fmt % ('S', S, S * temperature))
vrpint('=' * 49)
return S
[docs]
def get_helmholtz_energy(self, temperature, verbose=True):
"""Returns the Helmholtz free energy, in eV, in the hindered
translator and hindered rotor model at a specified temperature
(K)."""
self.verbose = True
vprint = self._vprint
U = self.get_internal_energy(temperature, verbose=verbose)
vprint('')
S = self.get_entropy(temperature, verbose=verbose)
F = U - temperature * S
vprint('')
vprint('Free energy components at T = %.2f K:' % temperature)
vprint('=' * 23)
fmt = '%5s%15.3f eV'
vprint(fmt % ('U', U))
vprint(fmt % ('-T*S', -temperature * S))
vprint('-' * 23)
vprint(fmt % ('F', F))
vprint('=' * 23)
return F
[docs]
class IdealGasThermo(BaseThermoChem):
"""Class for calculating thermodynamic properties of a molecule
based on statistical mechanical treatments in the ideal gas
approximation.
Inputs for enthalpy calculations:
vib_energies : list
a list of the vibrational energies of the molecule (e.g., from
ase.vibrations.Vibrations.get_energies). The number of expected
vibrations is calculated by the geometry and the number of atoms.
By default, the lowest energies are cut to enforce the expected
length. By setting vib_selection, this behaviour can be changed.
If either atoms and natoms is unspecified, the full list is used.
Units are eV.
geometry : 'monatomic', 'linear', or 'nonlinear'
geometry of the molecule
potentialenergy : float
the potential energy in eV (e.g., from atoms.get_potential_energy)
(if potentialenergy is unspecified, then the methods of this
class can be interpreted as the energy corrections)
natoms : integer
the number of atoms, used along with 'geometry' to determine how
many vibrations to use. (Not needed if an atoms object is supplied
in 'atoms' or if the user desires the entire list of vibrations
to be used.)
vib_selection : 'exact', 'highest' (default), 'abs_highest', 'all'
selection of input vibrational energies considered to be true
vibrations (excluding translations and rotations) implied by the
geometry and number of atoms. Only applied if number of atoms
is provided, either with natoms or atoms.
- 'exact': no selection; the number of input vibrational energies must
be equal to the number of true vibrations
- 'highest': select vibrational energies whose square is the highest
, i.e. large real energies followed by small real energies, small
imaginary energies and large imaginary energies. Will not catch
unrealistically large imaginary frequencies, resulting from e.g.
unrelaxed molecules. This is the default.
- 'abs_highest': select vibrational energies whose absolute number are
highest, real or imaginary. Will fail intentionally for
unrealistically large imaginary frequencies, but also unintentionally
for small real energies, corresponding e.g. to frustrated rotations
in a larger molecule.
- 'all': Use all input vibrational energies without checking the
number of them.
ignore_imag_modes : bool
If 'True', any imaginary frequencies present after the 3N-3 cut will
be removed in the calculation of the thermochemical properties.
If 'False' (default), an error will be raised if imaginary frequencies
are present after the 3N-3 cut.
Extra inputs needed for entropy / free energy calculations:
atoms : an ASE atoms object
used to calculate rotational moments of inertia and molecular mass
symmetrynumber : integer
symmetry number of the molecule. See, for example, Table 10.1 and
Appendix B of C. Cramer "Essentials of Computational Chemistry",
2nd Ed.
spin : float
the total electronic spin. (0 for molecules in which all electrons
are paired, 0.5 for a free radical with a single unpaired electron,
1.0 for a triplet with two unpaired electrons, such as O_2.)
"""
def __init__(self, vib_energies: Sequence[complex],
geometry: _GEOMETRY_OPTIONS,
potentialenergy: float = 0.,
atoms: Optional[Atoms] = None,
symmetrynumber: Optional[int] = None,
spin: Optional[float] = None,
natoms: Optional[int] = None,
vib_selection: Optional[_VIB_SELECTION_OPTIONS] = 'highest',
ignore_imag_modes: bool = False,
modes: Optional[Sequence[AbstractMode]] = None) -> None:
self.potentialenergy = potentialenergy
self.geometry = geometry
self.sigma = symmetrynumber
if natoms is None and atoms:
natoms = len(atoms)
# Preliminary sorting vibrations by square
vib_energies = list(vib_energies)
vib_energies.sort(key=lambda f: (f ** 2).real)
if natoms and vib_selection != 'all':
# Determine number of true vibrations from geometry
if geometry == 'nonlinear':
num_vibs = 3 * natoms - 6
elif geometry == 'linear':
num_vibs = 3 * natoms - 5
elif geometry == 'monatomic':
num_vibs = 0
else:
raise ValueError(f"Unsupported geometry: {geometry}")
if num_vibs < 0:
raise ValueError(
f"Number of atoms ({natoms}) too small for " +
f"geometry '{geometry}'."
)
if vib_selection == 'exact':
# Demand the expected number of true vibrations
if len(vib_energies) != num_vibs:
raise ValueError(
f"{num_vibs} vibrational energies expected, " +
f"{len(vib_energies)} received.\n" +
"To select true vibrational energies automatically," +
" set vib_selection."
)
elif vib_selection in ('highest', 'abs_highest'):
# Cut vibrations to get the expected number of true vibrations
if num_vibs == 0:
vib_energies = []
else:
if vib_selection == 'abs_highest':
vib_energies.sort(key=np.abs)
vib_energies = vib_energies[-num_vibs:]
if len(vib_energies) != num_vibs:
raise ValueError(
f"Too few vibration modes ({len(vib_energies)}) " +
"after selection. {num_vibs} expected"
)
else:
raise ValueError(
f"(Unsupported vib_selection: {vib_selection})"
)
elif not natoms and not atoms and vib_selection != 'all':
raise ValueError(
"Either natoms or atoms must be specified to select " +
"true vibrational energies. Else, set vib_selection to 'all'."
)
# Check for imaginary frequencies.
vib_energies, n_imag = _clean_vib_energies(
vib_energies, ignore_imag_modes)
super().__init__(vib_energies=vib_energies,
atoms=atoms,
spin=spin)
self.n_imag = n_imag
[docs]
def get_internal_energy(self, temperature: float,
verbose: bool = True) -> float:
"""Returns the internal energy, in eV, in the ideal gas approximation
at a specified temperature (K)."""
self.verbose = verbose
vprint = self._vprint
fmt = '%-15s%13.3f eV'
vprint('Enthalpy components at T = %.2f K:' % temperature)
vprint('=' * 31)
U = 0.
vprint(fmt % ('E_pot', self.potentialenergy))
U += self.potentialenergy
zpe = self.get_ZPE_correction()
vprint(fmt % ('E_ZPE', zpe))
U += zpe
Cv_tT = self.get_ideal_translational_energy(temperature)
vprint(fmt % ('Cv_trans (0->T)', Cv_tT))
U += Cv_tT
Cv_rT = self.get_ideal_rotational_energy(self.geometry, temperature)
vprint(fmt % ('Cv_rot (0->T)', Cv_rT))
U += Cv_rT
dU_v = self.get_vib_energy_contribution(temperature)
vprint(fmt % ('Cv_vib (0->T)', dU_v))
U += dU_v
vprint('-' * 31)
vprint(fmt % ('U', U))
vprint('=' * 31)
return U
[docs]
def get_enthalpy(self, temperature: float,
verbose: bool = True) -> float:
"""Returns the enthalpy, in eV, in the ideal gas approximation
at a specified temperature (K)."""
self.verbose = verbose
vprint = self._vprint
fmt = '%-15s%13.3f eV'
vprint('Enthalpy components at T = %.2f K:' % temperature)
vprint('=' * 31)
H = 0.
H += self.get_internal_energy(temperature, verbose=verbose)
Cp_corr = units.kB * temperature
vprint(fmt % ('(C_v -> C_p)', Cp_corr))
H += Cp_corr
vprint('-' * 31)
vprint(fmt % ('H', H))
vprint('=' * 31)
return H
[docs]
def get_entropy(self, temperature: float,
pressure: float = units.bar,
verbose: bool = True) -> float:
"""Returns the entropy, in eV/K, in the ideal gas approximation
at a specified temperature (K) and pressure (Pa)."""
if self.atoms is None or self.sigma is None or self.spin is None:
raise RuntimeError('atoms, symmetrynumber, and spin must be '
'specified for entropy and free energy '
'calculations.')
self.verbose = verbose
vprint = self._vprint
fmt = '%-15s%13.7f eV/K%13.3f eV'
vprint(f'Entropy components at T = {temperature:.2f} K and'
f' P = {pressure:.1f} Pa:')
vprint('=' * 49)
vprint('{"":15s}{"S":13s} {"T*S:13s}')
S, S_dict = self.get_ideal_entropy(temperature,
translation=True,
vibration=True,
rotation=True,
geometry=self.geometry,
electronic=True,
pressure=pressure,
symmetrynumber=self.sigma)
vprint(
fmt %
('S_trans (1 bar)',
S_dict['S_t'],
S_dict['S_t'] *
temperature))
vprint(fmt % ('S_rot', S_dict['S_r'], S_dict['S_r'] * temperature))
vprint(fmt % ('S_elec', S_dict['S_e'], S_dict['S_e'] * temperature))
vprint(fmt % ('S_vib', S_dict['S_v'], S_dict['S_v'] * temperature))
vprint(
fmt %
('S (1 bar -> P)',
S_dict['S_p'],
S_dict['S_p'] * temperature))
vprint('-' * 49)
vprint(fmt % ('S', S, S * temperature))
vprint('=' * 49)
return S
[docs]
def get_gibbs_energy(self, temperature: float,
pressure: float,
verbose: bool = True) -> float:
"""Returns the Gibbs free energy, in eV, in the ideal gas
approximation at a specified temperature (K) and pressure (Pa)."""
self.verbose = verbose
vprint = self._vprint
H = self.get_enthalpy(temperature, verbose=verbose)
vprint('')
S = self.get_entropy(temperature, pressure=pressure, verbose=verbose)
G = H - temperature * S
vprint('')
vprint('Free energy components at T = %.2f K and P = %.1f Pa:' %
(temperature, pressure))
vprint('=' * 23)
fmt = '%5s%15.3f eV'
vprint(fmt % ('H', H))
vprint(fmt % ('-T*S', -temperature * S))
vprint('-' * 23)
vprint(fmt % ('G', G))
vprint('=' * 23)
return G
[docs]
class CrystalThermo(BaseThermoChem):
"""Class for calculating thermodynamic properties of a crystalline
solid in the approximation that a lattice of N atoms behaves as a
system of 3N independent harmonic oscillators.
Inputs:
phonon_DOS : list
a list of the phonon density of states,
where each value represents the phonon DOS at the vibrational energy
value of the corresponding index in phonon_energies.
phonon_energies : list
a list of the range of vibrational energies (hbar*omega) over which
the phonon density of states has been evaluated. This list should be
the same length as phonon_DOS and integrating phonon_DOS over
phonon_energies should yield approximately 3N, where N is the number
of atoms per unit cell. If the first element of this list is
zero-valued it will be deleted along with the first element of
phonon_DOS. Units of vibrational energies are eV.
potentialenergy : float
the potential energy in eV (e.g., from atoms.get_potential_energy)
(if potentialenergy is unspecified, then the methods of this
class can be interpreted as the energy corrections)
formula_units : int
the number of formula units per unit cell. If unspecified, the
thermodynamic quantities calculated will be listed on a
per-unit-cell basis.
"""
[docs]
@classmethod
def from_transition_state(cls, *args, **kwargs) -> "CrystalThermo":
"""
Not yet implemented for CrystalThermo but needed to overwrite
BaseThermoChem method.
"""
raise NotImplementedError(
"from_transition_state is not implemented for CrystalThermo."
)
def __init__(self, phonon_DOS, phonon_energies,
formula_units=None, potentialenergy=0.):
self.phonon_energies = phonon_energies
self.phonon_DOS = phonon_DOS
if formula_units:
self.formula_units = formula_units
self.potentialenergy = potentialenergy / formula_units
else:
self.formula_units = 0
self.potentialenergy = potentialenergy
[docs]
def get_internal_energy(self, temperature, verbose=True):
"""Returns the internal energy, in eV, of crystalline solid
at a specified temperature (K)."""
self.verbose = verbose
vprint = self._vprint
fmt = '%-15s%13.4f eV'
if self.formula_units == 0:
vprint('Internal energy components at '
'T = %.2f K,\non a per-unit-cell basis:' % temperature)
else:
vprint('Internal energy components at '
'T = %.2f K,\non a per-formula-unit basis:' % temperature)
vprint('=' * 31)
U = 0.
omega_e = self.phonon_energies
dos_e = self.phonon_DOS
if omega_e[0] == 0.:
omega_e = np.delete(omega_e, 0)
dos_e = np.delete(dos_e, 0)
vprint(fmt % ('E_pot', self.potentialenergy))
U += self.potentialenergy
zpe_list = omega_e / 2.
if self.formula_units == 0:
zpe = trapezoid(zpe_list * dos_e, omega_e)
else:
zpe = trapezoid(zpe_list * dos_e, omega_e) / self.formula_units
vprint(fmt % ('E_ZPE', zpe))
U += zpe
B = 1. / (units.kB * temperature)
E_vib = omega_e / (np.exp(omega_e * B) - 1.)
if self.formula_units == 0:
E_phonon = trapezoid(E_vib * dos_e, omega_e)
else:
E_phonon = trapezoid(E_vib * dos_e, omega_e) / self.formula_units
vprint(fmt % ('E_phonon', E_phonon))
U += E_phonon
vprint('-' * 31)
vprint(fmt % ('U', U))
vprint('=' * 31)
return U
[docs]
def get_entropy(self, temperature, verbose=True):
"""Returns the entropy, in eV/K, of crystalline solid
at a specified temperature (K)."""
self.verbose = verbose
vprint = self._vprint
fmt = '%-15s%13.7f eV/K%13.4f eV'
if self.formula_units == 0:
vprint('Entropy components at '
'T = %.2f K,\non a per-unit-cell basis:' % temperature)
else:
vprint('Entropy components at '
'T = %.2f K,\non a per-formula-unit basis:' % temperature)
vprint('=' * 49)
vprint('%15s%13s %13s' % ('', 'S', 'T*S'))
omega_e = self.phonon_energies
dos_e = self.phonon_DOS
if omega_e[0] == 0.:
omega_e = np.delete(omega_e, 0)
dos_e = np.delete(dos_e, 0)
B = 1. / (units.kB * temperature)
S_vib = (omega_e / (temperature * (np.exp(omega_e * B) - 1.)) -
units.kB * np.log(1. - np.exp(-omega_e * B)))
if self.formula_units == 0:
S = trapezoid(S_vib * dos_e, omega_e)
else:
S = trapezoid(S_vib * dos_e, omega_e) / self.formula_units
vprint('-' * 49)
vprint(fmt % ('S', S, S * temperature))
vprint('=' * 49)
return S
[docs]
def get_helmholtz_energy(self, temperature, verbose=True):
"""Returns the Helmholtz free energy, in eV, of crystalline solid
at a specified temperature (K)."""
self.verbose = True
vprint = self._vprint
U = self.get_internal_energy(temperature, verbose=verbose)
vprint('')
S = self.get_entropy(temperature, verbose=verbose)
F = U - temperature * S
vprint('')
if self.formula_units == 0:
vprint('Helmholtz free energy components at '
'T = %.2f K,\non a per-unit-cell basis:' % temperature)
else:
vprint('Helmholtz free energy components at '
'T = %.2f K,\non a per-formula-unit basis:' % temperature)
vprint('=' * 23)
fmt = '%5s%15.4f eV'
vprint(fmt % ('U', U))
vprint(fmt % ('-T*S', -temperature * S))
vprint('-' * 23)
vprint(fmt % ('F', F))
vprint('=' * 23)
return F
def _clean_vib_energies(vib_energies: Sequence[complex],
ignore_imag_modes: bool = False
) -> Tuple[Sequence[float], int]:
"""Checks and deal with the presence of imaginary vibrational modes
Also removes +0.j from real vibrational energies.
Inputs:
vib_energies : list
a list of the vibrational energies
ignore_imag_modes : bool
If 'True', any imaginary frequencies will be removed.
If 'False' (default), an error will be raised if imaginary
frequencies are present.
Outputs:
vib_energies : list
a list of the real vibrational energies.
n_imag : int
the number of imaginary frequencies treated.
"""
# raise to a value, accept int and float in contrast to documentation
if ignore_imag_modes:
n_vib_energies = len(vib_energies)
vib_energies = [np.real(v) for v in vib_energies if np.real(v) > 0]
n_imag = n_vib_energies - len(vib_energies)
if n_imag > 0:
warn(f"{n_imag} imag modes removed", UserWarning)
else:
if sum(np.iscomplex(vib_energies)):
raise ValueError('Imaginary vibrational energies are present.')
n_imag = 0
ret = [np.real(v) for v in vib_energies] # clear +0.j
return ret, n_imag