"""(Quasi)harmonic approximation. Thermal expansion tools."""
import warnings
import numpy as np
from scipy.integrate import trapezoid as trapz
from pwtools.constants import kb, hplanck, R, pi, c0, Ry_to_J, eV,\
eV_by_Ang3_to_GPa
from pwtools.verbose import verbose
from pwtools import num, mpl
[docs]
def coth(x):
return 1.0/np.tanh(x)
[docs]
class HarmonicThermo:
"""Calculate vibrational internal energy (Evib [eV]), free energy (Fvib
[eV]), entropy (Svib [R,kb]) and isochoric heat capacity (Cv [R,kb]) in the
harmonic approximation from a phonon density of states.
"""
[docs]
def __init__(self, freq, dos, T=None, temp=None, skipfreq=False,
eps=1.5*num.EPS, fixnan=False, nanfill=0.0,
dosarea=None, integrator=trapz, verbose=True):
"""
Parameters
----------
freq : 1d array
frequency f (NOT 2*pi*f) [cm^-1]
dos : 1d array
phonon dos such that int(freq) dos = 3*natom
T : 1d array, optional
temperature range [K], if not given in the constructor then use
`temp` in the calculation methods
skipfreq : bool, optional
Ignore frequencies and DOS values where the frequencies are
negative or close to zero, i.e. all DOS curve values where `freq` <
`eps`. The number and rms of the skipped values is printed if
`verbose=True`.
eps : float, optional
Threshold for `skipfreq`. Default is ~1.5*2.2e-16 .
fixnan : bool, optional
Use if YKWYAD, test before using! Currently, set all NaNs occurring
during integration to `nanfill`. This is a HACK b/c we must assume
that these numbers should be `nanfill`.
nanfill : float, optional
During integration over temperature, set NaNs to this value.
dosarea : float or None
If not None, then re-normalize the area int(freq) dos to `dosarea`,
after `skipfreq` was applied if used.
integrator : callable
Function which integrates x-y data. Called as ``integrator(y,x=x)``,
like ``scipy.integrate.{trapz,simps}``. Usually, `trapz` is
numerically more stable for weird DOS data and accurate enough if
the frequency axis resolution is good.
verbose : bool, optional
Print warnings. Recommended for testing.
Notes
-----
`skipfreq` and `fixnan`: Sometimes, a few frequencies (usually the 1st
few values only) are close to zero and negative, and the DOS is very
small there. `skipfreq` can be used to ignore this region. The default
is False b/c it may hide large negative frequencies (i.e. unstable
structure), which is a perfectly valid result (but you shouldn't do
thermodynamics with that :) Even if there are no negative frequencies,
you can have frequencies (usually the first) being exactly zero or
close to that (order 1e-17). That can cause numerical problems (NaNs)
in some calculations so we may skip them and their DOS values, which
must be assumed to be small. If you still encounter NaNs during
integration, you may use `fixnan` to set them to `nanfill`. But that is a
hack. If you cannot get rid of NaNs by `skipfreq`, then your freq-dos
data is probably fishy!
"""
# Notes
# -----
# - This is actually a re-implementation of F_QHA.f90 found in Quantum
# Espresso as of v4.2.
# - All relations can be found in M.T. Dove, Introduction to Lattice
# Dynamics, ch. 5 .
# - The frequency axis "f" in cm^-1 is what QE's matdyn.x returns
# when it calculates the phonon DOS (input: dos=.true.).
# - For high T, Cv in units of R, the universal gas constant, should
# approach 3*N where N = natom = atoms in the unit cell. This is the
# Dulong-Petit limit (usually 3*N*R, here 3*N).
#
# Theory (example Cv):
# --------------------
#
# Let Z = hbar*w/(2*kb*T), D(w) = phonon dos.
# Cv(T) = kb * Z**2 * Int(w) [w**2 * D(w) / sinh(z))**2]
#
# Cv is in J/K. To get Cv in R[J/(mol*K)], one would have to do
# Cv[J/K] * Navo[1/mol] / R = Cv[J/K] / kb[J/K]
# since kb = R/Navo, with
# R = gas constant = 8.314 J/(mol*K)
# Navo = Avogadro's number = 6e23
#
# So, Cv [R] == Cv [kb]. The same holds for the entropy Svib.
#
# We save the division by "kb" by dropping the "kb" prefactor:
# Cv(T) = Z**2 * Int(w) [w**2 * D(w) / sinh(z))**2]
# ^^^^
# random note:
#
# in F_QHA.f90:
# a3 = 1.0/8065.5/8.617e-5 # = hbar*c0*100*2*pi / kb
# Did you know that?
#
# All formulas (cv, fvib etc) are written for angular frequency
# w=2*pi*freq. Either we use hbar*w or h*freq. We do the latter.
# We also convert s -> cm and keep freq in [cm^-1].
#
# cm^-1 -> 1/s : * c0*100
# 1/s -> cm^-1 : / (c0*100)
# s -> cm : * c0*100
# => hbar or h: J*s -> J*cm : *c0*100
if temp is None:
self.T = T
else:
warnings.warn("'temp' is deprecated, use 'T'", DeprecationWarning)
assert T is None, "Can't use both T and temp."
self.T = temp
self.f = freq
self.dos = dos
self.h = hplanck * c0 * 100
self.kb = kb
self.fixnan = fixnan
self.skipfreq = skipfreq
self.verbose = verbose
self.eps = eps
self.nanfill = nanfill
self.dosarea = dosarea
self.integrator = integrator
assert len(self.f) == len(self.dos), ("freq and dos don't have "
"equal length")
if self.verbose:
print("HarmonicThermo: number of dos points: %i" %len(self.f))
if self.skipfreq:
mask = self.f > self.eps
if self.verbose:
imask = np.invert(mask)
nskip = len(imask.nonzero()[0])
if len(imask) > 0:
frms = num.rms(self.f[imask])
drms = num.rms(self.dos[imask])
self._printwarn("HarmonicThermo: skipping %i dos points: "
"rms(f)=%e, rms(dos)=%e" %(nskip, frms, drms))
self.f = self.f[mask]
self.dos = self.dos[mask]
if self.dosarea is not None:
self.dos = self._norm_int(self.dos, self.f, area=float(self.dosarea))
def _norm_int(self, y, x, area):
fx = np.abs(x).max()
fy = np.abs(y).max()
sx = x / fx
sy = y / fy
_area = self.integrator(sy, x=sx) * fx * fy
return y*area/_area
def _printwarn(self, msg):
if self.verbose:
print(msg)
def _integrate(self, y, f):
"""
Integrate `y` along axis=1, i.e. over freq axis for all T.
Parameters
----------
y : 2d array (nT, ndos) where nT = len(self.T), ndos = len(self.dos)
f : self.f, (len(self.dos),)
Returns
-------
array (nT,)
"""
mask = np.isnan(y)
if mask.any():
self._printwarn("HarmonicThermo._integrate: warning: "
" %i NaNs found in y!" %len(mask))
if self.fixnan:
self._printwarn("HarmonicThermo._integrate: warning: "
"fixing %i NaNs in y!" %len(mask))
y[mask] = self.nanfill
# this call signature works for scipy.integrate,{trapz,simps}
return self.integrator(y, x=f, axis=1)
def _get_temp(self, temp):
if (self.T is None) and (temp is None):
raise ValueError("temp input and self.T are None")
return self.T if temp is None else temp
[docs]
def vibrational_internal_energy(self, temp=None):
"""Evib [eV]"""
h, f, kb, dos = self.h, self.f, self.kb, self.dos
T = self._get_temp(temp)
arg = h * f / (kb*T[:,None])
y = dos * f * (0.5 + 1.0 / (np.exp(arg) - 1.0))
eint = self._integrate(y, f)
return eint * (h/eV)
[docs]
def isochoric_heat_capacity(self, temp=None):
"""Cv [R, kb]"""
h, f, kb, dos = self.h, self.f, self.kb, self.dos
T = self._get_temp(temp)
arg = h * f / (kb*T[:,None])
y = dos * f**2 / np.sinh(arg / 2.0)**2.0
fac = (h / (kb*2*T))**2
cv = self._integrate(y, f) * fac
return cv
[docs]
def vibrational_free_energy(self, temp=None):
"""Fvib [eV]"""
h, f, kb, dos = self.h, self.f, self.kb, self.dos
T = self._get_temp(temp)
arg = h * f / (kb*T[:,None])
y = dos * np.log(2.0*np.sinh(arg/2.0))
ret = self._integrate(y,f) * T
return ret * (kb / eV)
[docs]
def vibrational_entropy(self, temp=None):
"""Svib [R, kb]"""
h, f, kb, dos = self.h, self.f, self.kb, self.dos
T = self._get_temp(temp)
arg = h * f / (kb*T[:,None])
y = dos * (0.5/T[:,None] * (h / kb) * f * coth(arg/2.0) -
np.log(2.0*np.sinh(arg/2.0)))
ret = self._integrate(y,f)
return ret
# aliases
[docs]
def evib(self, *args, **kwargs):
"""Same as vibrational_internal_energy()."""
return self.vibrational_internal_energy(*args, **kwargs)
[docs]
def cv(self, *args, **kwargs):
"""Same as isochoric_heat_capacity()."""
return self.isochoric_heat_capacity(*args, **kwargs)
[docs]
def fvib(self, *args, **kwargs):
"""Same as vibrational_free_energy()."""
return self.vibrational_free_energy(*args, **kwargs)
[docs]
def svib(self, *args, **kwargs):
"""Same as vibrational_entropy()."""
return self.vibrational_entropy(*args, **kwargs)
[docs]
class Gibbs:
"""
Calculate thermodynamic properties on a T-P grid in the quasiharmonic
approximation, given some variation grid of unit cell axes (`axes_flat`) and
corresponding phonon DOS data for each grid point.
The `axes_flat` approach allows us to easily calculate per-axis thermal
expansion rates.
We have 3 cases for how unit cell axis can be be varied.
==== =============== ======================== ============== ============
case axes_flat.shape cell parameter grid fitfunc bulk modulus
==== =============== ======================== ============== ============
1d (N,) or (N,1) ax0 = a (-> V) G(V), thus any V*d^2G/dV^2
(e.g. cubic or EOS will do
rhombohedral)
2d (N,2) (ax0,ax1) = (a,b), (a,c) G(ax0, ax1) not
or (b,c) defined
(e.g. hexagonal: (a,c))
3d (N,3) (ax0,ax1,ax2) no default not
(e.g. triclinic) defined
==== =============== ======================== ============== ============
Internally, we only deal with `ax0` (1d), `ax0` + `ax1` (2d) and
`ax0` + `ax1` + `ax2` (3d), thus for 2d and 3d it doesn't matter which cell
axes is which, you just have to remember :)
Notes
-----
Functions for fitting G(V), V(T) etc are defined in a dictionary attribute
``fitfunc``. This is a dict with functions, which have the signature
``func(x,y)``. The functions get x-y type data and return a class instance.
The instances must be a :class:`~pwtools.num.Spline`-like object with a
``get_min()`` method. The ``__call__()`` method must accept a keyword `der`
for calculating derivatives in some cases. See `self._default_fit_*` to get
an idea: for 1d :class:`~pwtools.num.Spline` or
:class:`~pwtools.num.PolyFit1D`, for 2d: :class:`~pwtools.num.PolyFit` or
:class:`~pwtools.num.Interpol2D`. See also ``scipy.interpolate``. Use
:meth:`~pwtools.thermo.Gibbs.set_fitfunc` to change. This can (and should!)
be used to tune fitting methods. See examples below for how to change fit
functions.
Here is a list of all valid `fitfunc` keys and what they do:
======== ==============================================================
key value
======== ==============================================================
'1d-G' fit G(V), ``__call__(V,der=2)`` for B(V) =
V*d^2G/dV^2
'2d-G' fit G(ax0,ax1)
'3d-G' fit G(ax0,ax1,ax2), no default, define and test youself,
please
'1d-ax' fit V(ax0)
'alpha' fit x_opt(T), x=ax0,ax1,ax2,V,
``__call__(T,der=1)`` for alpha_x = 1/x * dx/dT
'C' fit G_opt(T), ``__call__(T,der=2)`` for
Cp = -T * d^G_opt(T,P)/dT^2
======== ==============================================================
Hints for which fitfuncs to choose: The defaults are pretty good, but
nevertheless testing is mandatory! Good choices are
:class:`~pwtools.num.PolyFit` / :class:`~pwtools.num.PolyFit1D` for all
axes-related grids (1d-G, 2d-G, 1d-ax) since you won't have too many
points here. Then a fit is better than a :class:`~pwtools.num.Spline`.
Always use ``PolyFit(..., scale=True)`` or scale the x-y data manually
before fitting to the same order of magnitude! For T grids, choose a very
fine T-axis (e.g. ``T=linspace(.., num=300)`` and use a
:class:`~pwtools.num.Spline`. Needed to resolve details of alpha(T) and
C(T).
The methods :meth:`calc_F`, :meth:`calc_H` and :meth:`calc_G` return dicts
with nd-arrays holding calculated thermodynamic properties. Naming
convention for dict keys returned by methods: The keys (strings) mimic HDF5
path names, e.g. ``/group0/group1/array``, thus the last name in the path
is the name of the array (`z` in the examples below). All previous names
define the grid and thus the dimension of the array. A group name starting
with "#" is just a prefix. Below, `na=len(a)`, etc.
============== ==== ================ ================================
key ndim shape description
============== ==== ================ ================================
``/a/z`` 1d (na,) z on 1d a-grid
``/a/b/z`` 2d (na,nb) z on 2d a-b grid
``/a/b/c/z`` 3d (na,nb,nc) z on 3d a-b-c grid
``/a-b/z`` 1d (na*nb,) z on flattened a-b grid
``/a-b-c/z`` 1d (na*nb*nc,) z on flattened a-b-c grid
``/#opt/a/z`` 1d (na,) optimized z on a-grid
============== ==== ================ ================================
`axes_flat` : Usually, flat grids like "ax0-ax1" or "ax0-ax1-ax2" are
created by nested loops ``[(ax0_i,ax1_i) for ax0_i in ax0 for ax1_i in
ax1]`` and therefore have shape (nax0*nax1,2) or (nax0*nax1*nax2,3) . But
that is not required. They can be completely unstructured (e.g. if points
have been added later to the grid manually) -- only `fitfunc` must be able
to handle that.
Units
=================== =====================
B,P GPa
T K
F,G eV
ax{0,1,2} Ang
Cv,Cp R (8.4314 J/(mol*K))
alpha_{V,ax{0,1,2}} 1/K
=================== =====================
Examples
--------
See ``test/test_gibbs.py`` for worked examples using fake data.
Really, go there and have a look. Now!
>>> from pwtools import mpl, crys, eos
>>> # cubic cell, 1d case
>>> volfunc_ax = lambda x: crys.volume_cc(np.array([[x[0]]*3 + [90]*3]))
>>> gibbs=Gibbs(axes_flat=..., etot=..., phdos=...,
... T=linspace(5,2500,100), P=linspace(0,20,5),
... volfunc_ax=volfunc_ax)
>>> # EOS fit for G(V), polynomial for heat capacity (default is Spline)
>>> gibbs.set_fitfunc('C', lambda x,y: num.PolyFit1D(x,y,deg=5,scale=True))
>>> gibbs.set_fitfunc('1d-G', lambda x,y: eos.EosFit(x,y))
>>> g = gibbs.calc_G(calc_all=True)
>>> # 1d case
>>> V = g['/ax0/V']; G=g['/T/P/ax0/G']; T=g['/T/T']
>>> # plot G(V) for all T and P=20 GPa
>>> plot(V, G[:,-1,:].T)
>>> # Cp(T) for all P
>>> plot(T,g['/#opt/T/P/Cp'])
>>> # 2d case plot G(ax0,ax1) for T=2500 K, P=0 GPa
>>> G=g['/T/P/ax0-ax1/G']
>>> d=mpl.Data2D(XY=axes_flat, zz=G[-1,0,:])
>>> fig,ax=mpl.fig_ax3d(); ax.scatter(d.xx,d.yy,d.zz); show()
"""
[docs]
def __init__(self, T=None, P=None, etot=None, phdos=None, axes_flat=None,
volfunc_ax=None, case=None, **kwds):
"""
Parameters
----------
T : 1d array
temperature [K]
P : 1d array
pressure [GPa]
etot : 1d array, (axes_flat.shape[0],)
Total energy [eV] for each axes_flat[i,...]
phdos : sequence of 2d arrays (axes_flat.shape[0],)
Phonon dos arrays (nfreq, 2) for each axes grid point.
axes_flat[i,...] -> phdos[i] = <2d array (nfreq,2)>, units see
:class:`HarmonicThermo`. For each array ``phdos[i][:,0] = freq``,
``phdos[i][:,1] = dos``. Those are passed to
:class:`HarmonicThermo`.
axes_flat : 1d or 2d array
Flattened cell axes variation grid (for example result of nested
loop over axes to vary). Will be cast to shape (N,1) if 1d with
shape (N,) .
| 1d: (N,) or (N,1) -> vary one cell axis, i.e. cubic cell
| 2d: (N,2) -> vary 2 (e.g. a and c for hexagonal)
| example: ``itertools.product(ax0, ax1)``
| 3d: (N,3) -> vary a,b,c (general triclinic)
| example: ``itertools.product(ax0, ax1, ax2)``
volfunc_ax : callable
calculate cell volume based on cell axes,
V[i] = volfunc_ax(axes_flat[i,...]) where axes_flat[i,...]:
| 1d: [a0]
| 2d: [a0, a1]
| 3d: [a0, a1, a2]
with a0,a1,a2 the length of the unit cell axes.
case : str, optional
'1d', '2d', '3d' or None. If None then it will be determined from
axes_flat.shape[1]. Can be used to evaluate "fake" 1d data: set
case='1d' but let `axes_flat` be (N,2) or (N,3)
**kwds: keywords
passed to HarmonicThermo and added here as `self.<key>=<value>`
"""
assert axes_flat.shape[0] == len(phdos), ("axes_flat and phdos "
"not equally long")
assert axes_flat.shape[0] == len(etot), ("axes_flat and etot "
"not equally long")
self.kwds = dict(verbose=False, fixnan=False, skipfreq=True,
dosarea=None)
self.kwds.update(kwds)
for k,v in self.kwds.items():
setattr(self, k, v)
self.T = T
self.P = P
self.etot = etot
self.phdos = phdos
self.volfunc_ax = volfunc_ax
self.axes_flat = axes_flat if axes_flat.ndim == 2 else axes_flat[:,None]
self.nT = len(self.T)
self.nP = len(self.P)
self.npoints = self.axes_flat.shape[0]
self.nax = self.axes_flat.shape[1]
self.V = np.array([self.volfunc_ax(self.axes_flat[ii,...]) for ii \
in range(self.npoints)])
self.case = case
self.fitax = None
if self.nax == 1:
if self.case is None:
self.case = '1d'
self.axes_prefix = '/ax0'
elif self.nax == 2:
if self.case is None:
self.case = '2d'
self.axes_prefix = '/ax0-ax1'
elif self.nax == 3:
if self.case is None:
self.case = '3d'
self.axes_prefix = '/ax0-ax1-ax2'
else:
raise ValueError(f"{self.axes_flat.shape=} not supported")
self.ret = {}
self.ret['/T/T'] = self.T
self.ret['/P/P'] = self.P
self.ret['%s%s' %((self.axes_prefix,)*2)] = self.axes_flat
self.ret['%s/V' %self.axes_prefix] = self.V
self.ret[self.axes_prefix + '/Etot'] = self.etot
self.fitfunc = {\
'1d-G': self._default_fit_1d_G,
'2d-G': self._default_fit_2d_G,
'3d-G': self._default_fit_3d_G,
'1d-ax': self._default_fit_1d_ax,
'alpha': self._default_fit_alpha,
'C': self._default_fit_C,
}
[docs]
def set_fitfunc(self, what, func):
"""Update dict with fitting functions: ``self.fitfunc[what] = func``.
Parameters
----------
what : str
One of ``self.fitfunc.keys()``
func : callable
function with signature ``func(x,y)`` which returns a
:class:`~pwtools.num.Spline`-like object, e.g. ``Spline(x,y,k=5)``
"""
assert what in self.fitfunc, ("unknown key: '%s'" %what)
self.fitfunc[what] = func
@staticmethod
def _default_fit_1d_G(x, y):
return num.PolyFit1D(x, y, deg=5, scale=True)
@staticmethod
def _default_fit_2d_G(points, values):
return num.PolyFit(points, values, deg=5, scale=True)
@staticmethod
def _default_fit_3d_G(points, values):
##return num.PolyFit(points, values, deg=5, scale=True)
# PolyFit probably works, but force user to specify own 3d fit method.
# Probably sklearn.gaussian_process.GaussianProcessRegressor or smth is
# even better.
raise NotImplementedError(
"Please set a 3d fit method using Gibbs.set_fitfunc()"
)
@staticmethod
def _default_fit_1d_ax(x, y):
return num.PolyFit1D(x, y, deg=5, scale=True)
@staticmethod
def _default_fit_alpha(x, y):
return num.Spline(x, y, k=5, s=None)
@staticmethod
def _default_fit_C(x, y):
return num.Spline(x, y, k=5, s=None)
def _fit_opt_store(self, ret, gg, prfx='/T/P', ghsym='G', tpidx=None):
"""For each (T,P) or (P,), fit G(ax0,...) or H(ax0,...) minimize and store
properties in `ret`. Used in /T/P loop in calc_G and /P loop in calc_H.
Parameters
----------
ret : dict
gg : G(ax0,...) or H(ax0,...)
prfx : str
'/P' for H in :meth:`calc_H`, '/T/P' for G in :meth:`calc_G`
ghsym : str
'H' or 'G'
tpidx : list
[tidx,pidx] in calc_G
[pidx] in calc_H
"""
tpsl = tuple(tpidx)
if self.case == '1d':
fit = self.fitfunc['1d-G'](self.V, gg)
vopt = fit.get_min()
ret['/#opt%s/V' %prfx][tpsl] = vopt
ret['/#opt%s/%s' %(prfx,ghsym)][tpsl] = fit(vopt)
# Loop needed for fake-1d case when we set case='1d'
# by hand but self.axes_flat.shape = (N,2) or (N,3). Also,
# we fit G(V) and not G(ax0) for that reason.
for iax in range(self.nax):
ret['/#opt%s/ax%i' %(prfx,iax)][tpsl] = self.fitax[iax](vopt)
ret['/#opt%s/B' %prfx][tpsl] = vopt * fit(vopt, der=2) * eV_by_Ang3_to_GPa
elif self.case == '2d':
assert self.axes_flat.shape[1] == 2, (
f"{self.axes_flat.shape[1]=} makes no sense for {self.case=}"
)
# XXX The fit function alone should take care of scaling, see
# num.PolyFit(..., scale=True). Doing this here is OK but redundant.
# Maybe also introduces additional numerical noise?
ggmin = gg.min()
ggmax = gg.max()
ggscale = (gg - ggmin) / (ggmax - ggmin)
fit = self.fitfunc['2d-G'](self.axes_flat, ggscale)
xopt = fit.get_min()
ret['/#opt%s/ax0' %prfx][tpsl] = xopt[0]
ret['/#opt%s/ax1' %prfx][tpsl] = xopt[1]
ret['/#opt%s/%s' %(prfx, ghsym)][tpsl] = fit(xopt) * (ggmax - ggmin) + ggmin
if self.volfunc_ax is not None:
ret['/#opt%s/V' %prfx][tpsl] = self.volfunc_ax(xopt)
elif self.case == '3d':
##assert self.axes_flat.shape[1] == 3, (
## f"{self.axes_flat.shape[1]=} makes no sense for {self.case=}"
##)
# We have _default_fit_3d_G (raises NotImplementedError ATM), but
# need to test a modification of the fitting business of case=2d
# with a real-world use case before implementing this feature
# publicly.
raise ValueError("Fits for 3d-G not yet supported, only fake-1d where "
"case='1d' but axes_flat.shape[1] > 1.")
else:
raise ValueError(f"Unknown {case=}")
def _set_not_calc_none(self, ret, prfx='/T/P'):
"""We know that the stuff below was not calculated, so set them to
None. Needs to be done since all arrays are initialized to be np.empty().
Little hackish, but OK For Me (tm)."""
if self.volfunc_ax is None:
ret['/#opt%s/V' %prfx] = None
if self.nax == 1:
ret['/#opt%s/ax1' %prfx] = None
ret['/#opt%s/ax2' %prfx] = None
elif self.nax == 2:
ret['/#opt%s/ax2' %prfx] = None
if self.case != '1d':
ret['/#opt%s/B' %prfx] = None
def _set_fitax(self):
"""Store list of Spline-like fit objects, one for each ax. Each
fit(V,ax) maps the volume to each axis. Only used if self.case=='1d'."""
if self.fitax is None:
self.fitax = []
for iax in range(self.nax):
self.fitax.append(self.fitfunc['1d-ax'](self.V,
self.axes_flat[:,iax]))
[docs]
def calc_F(self, calc_all=True):
"""Free energy properties along T axis for each axes grid point
(ax0,ax1,ax2) in `self.axes_flat`. Also used by :meth:`calc_G`. Uses
:class:`~pwtools.thermo.HarmonicThermo`.
Parameters
----------
calc_all : bool
Calculate only F, Fvib or all (F, Fvib, Evib, Svib, Cv)
Returns
-------
ret : dict
Keys for 1d:
| '/ax0/T/F'
| '/ax0/T/Fvib'
...
Keys for 2d:
| '/ax0-ax1/T/F'
| '/ax0-ax1/T/Fvib'
| ...
Keys for 3d:
| '/ax0-ax1-ax2/T/F'
| '/ax0-ax1-ax2/T/Fvib'
| ...
"""
self._set_fitax()
if calc_all:
names = ['F', 'Fvib', 'Evib', 'Svib', 'Cv']
else:
names = ['F', 'Fvib']
ret = dict((self.axes_prefix + '/T/%s' %name,
np.empty((self.npoints, self.nT), dtype=float)) \
for name in names)
for idx in range(self.npoints):
if self.verbose:
print("calc_F: axes_flat idx = %i" %idx)
ha = HarmonicThermo(freq=self.phdos[idx][:,0],
dos=self.phdos[idx][:,1],
T=self.T,
**self.kwds)
fvib = ha.fvib()
ret[self.axes_prefix + '/T/F'][idx,:] = self.etot[idx] + fvib
ret[self.axes_prefix + '/T/Fvib'][idx,:] = fvib
if calc_all:
svib = ha.svib()
cv = ha.cv()
evib = fvib + self.T * svib # or call ha.evib()
ret[self.axes_prefix + '/T/Svib'][idx,:] = svib
ret[self.axes_prefix + '/T/Evib'][idx,:] = evib
ret[self.axes_prefix + '/T/Cv'][idx,:] = cv
ret.update(self.ret)
return ret
[docs]
def calc_H(self, calc_all=True):
"""Enthalpy H=E+P*V, B and related properties on P grid
without zero-point contributions. Doesn't need any other method's
results.
The results of :meth:`calc_G` will in general be _different_ from those
calculated here in the limit T->0 (typical use case: T=5K) because of
zero-point contributions. Check the bulk modulus, for example.
"""
ret = {}
self._set_fitax()
ret['/P' + self.axes_prefix + '/H'] = np.empty((self.nP,self.npoints), dtype=float)
for pidx in range(self.nP):
gg = self.etot + self.V * self.P[pidx] / eV_by_Ang3_to_GPa
ret['/P' + self.axes_prefix + '/H'][pidx,:] = gg
if calc_all:
names = ['ax0', 'ax1', 'ax2', 'V', 'H', 'B']
ret.update(dict(('/#opt/P/%s' %name, np.empty((self.nP,))) \
for name in names))
for pidx in range(self.nP):
if self.verbose:
print("calc_H: pidx = %i" %(pidx))
gg = ret['/P' + self.axes_prefix + '/H'][pidx,:]
self._fit_opt_store(ret, gg, prfx='/P',
tpidx=[pidx], ghsym='H')
self._set_not_calc_none(ret, prfx='/P')
ret.update(self.ret)
return ret
[docs]
def calc_G(self, ret=None, calc_all=True):
"""Gibbs free energy and related properties on T-P grid.
Uses self.fitfunc.
Needs :meth:`calc_F` results. Called here if not provided.
Also calls :meth:`calc_H` if `calc_all` is ``True``, i.e. this is the
you-get-it-all method and the only one you should really use.
Parameters
----------
ret : dict, optional
Result from calc_F(). If None then calc_F() is called here. Can be
used to add additional contributions to F, such as electronic
entropy.
calc_all : bool
Calculate thermal properties from G(ax0,ax1,ax2,T,P): Cp,
alpha_x, B. If False, then calculate and store only G.
Returns
-------
ret : dict
All keys starting with the ``/#opt`` prefix are values obtained
from minimizing G(ax0,ax1,ax2,T,P) w.r.t. (ax0,ax1,ax2).
"""
self._set_fitax()
if ret is None:
ret = self.calc_F(calc_all=calc_all)
ret['/T/P' + self.axes_prefix + '/G'] = np.empty((self.nT,self.nP,self.npoints), dtype=float)
for tidx in range(self.nT):
for pidx in range(self.nP):
gg = ret[self.axes_prefix + '/T/F'][:,tidx] + self.V * self.P[pidx] / eV_by_Ang3_to_GPa
ret['/T/P' + self.axes_prefix + '/G'][tidx,pidx,:] = gg
if calc_all:
ret.update(self.calc_H(calc_all=calc_all))
names = ['ax0', 'ax1', 'ax2', 'V', 'G', 'B']
ret.update(dict(('/#opt/T/P/%s' %name, np.empty((self.nT,self.nP))) \
for name in names))
for tidx in range(self.nT):
for pidx in range(self.nP):
if self.verbose:
print("calc_G: tidx = %i, pidx = %i" %(tidx,pidx))
gg = ret['/T/P' + self.axes_prefix + '/G'][tidx,pidx,:]
self._fit_opt_store(ret, gg, prfx='/T/P',
tpidx=[tidx,pidx], ghsym='G')
self._set_not_calc_none(ret, prfx='/T/P')
alpha_names = ['ax0', 'ax1', 'ax2', 'V']
ret.update(dict(('/#opt/T/P/alpha_%s' %name,
np.empty((self.nT,self.nP))) for name in \
alpha_names))
for name in alpha_names:
arr = ret['/#opt/T/P/%s' %name]
if arr is not None:
for pidx in range(self.nP):
x = arr[:,pidx]
fit = self.fitfunc['alpha'](self.T, x)
ret['/#opt/T/P/alpha_%s' %name][:,pidx] = fit(self.T, der=1) / x
else:
ret['/#opt/T/P/alpha_%s' %name] = None
ret['/#opt/T/P/Cp'] = np.empty((self.nT, self.nP), dtype=float)
for pidx in range(self.nP):
fit = self.fitfunc['C'](self.T, ret['/#opt/T/P/G'][:,pidx]*eV/kb)
ret['/#opt/T/P/Cp'][:,pidx] = -self.T * fit(self.T, der=2)
ret.update(self.ret)
return ret
[docs]
def debye_func(x, nstep=100, zero=1e-8):
r"""Debye function
:math:`f(x) = 3 \int_0^1 t^3 / [\exp(t x) - 1] dt`
Parameters
----------
x : float or 1d array
nstep : int
number of points for integration
zero : float
approximate the 0 in the integral by this (small!) number
"""
x = np.atleast_1d(x)
if x.ndim == 1:
x = x[:,None]
else:
raise Exception("x is not 1d array")
tt = np.linspace(zero, 1.0, nstep)
return 3.0 * trapz(tt**3.0 / (np.exp(tt*x) - 1.0), tt, axis=1)
[docs]
def einstein_func(x):
r"""Einstein function
:math:`f(x) = 1 / [ \exp(x) - 1 ]`
Parameters
----------
x : float or 1d array
"""
x = np.atleast_1d(x)
return 1.0 / (np.exp(x) - 1.0)
[docs]
def expansion(temp, alpha, theta, x0=1.0, func=debye_func):
"""Calculate thermal expansion according to the model in `func`.
Parameters
----------
temp : array_like
temperature
alpha : float
high-T limit expansion coeff
theta
Debye or Einstein temperature
x0 : float
axis length at T=0
func : callable
Usually :func:`debye_func` or :func:`einstein_func`
Examples
--------
>>> # thermal expansion coeff alpha_x = 1/x(T) * dx/dT
>>> from pwtools.thermo import expansion, debye_func, einstein_func
>>> from pwtools import num
>>> T=linspace(5,2500,100)
>>> for zero in [1e-3, 1e-8]:
>>> x = expansion(T, 5e-6, 1200, 3, lambda x: debye_func(x,zero=zero))
>>> plot(T, num.deriv_spl(x, T, n=1)/x)
>>> x = expansion(T, 5e-6, 1200, 3, einstein_func)
>>> plot(T, num.deriv_spl(x, T, n=1)/x)
References
----------
[1] Figge et al., Appl. Phys. Lett. 94, 101915 (2009)
"""
return x0 * (1.0 + alpha * theta * func(theta / temp))