"""
EOS fitting. Use :class:`EosFit` (only Vinet EOS for now).
Also: Old interface class :class:`ElkEOSFit` and the base class
:class:`ExternEOS` for calling extern EOS fitting applications. Compile the app
to produce an executable, e.g. ``eos.x``. Then use ``app='/path/to/eos.x'`` in
the constructor. Has another API than :class:`EosFit`, e.g. the
:meth:`EosFit.get_min` method is different from :meth:`ElkEOSFit.get_min`.
"""
import os
import subprocess, types, warnings
import numpy as np
from pwtools import common, constants, num
from pwtools.constants import Ry, Ha, Bohr, Ang, eV, eV_by_Ang3_to_GPa
from pwtools.base import FlexibleGetters
from pwtools.decorators import lazyprop
from pwtools.num import Fit1D
from scipy.optimize import leastsq
[docs]
def _vinet(V, params):
"""Vinet equation from PRB 70, 224107, from pymatgen."""
E0, B0, B1, V0 = params['e0'], params['b0'], params['b1'], params['v0']
eta = (V/V0)**(1./3.)
return E0 + 2.*B0*V0/(B1-1.)**2 \
* (2. - (5. +3.*B1*(eta-1.)-3.*eta)*np.exp(-3.*(B1-1.)*(eta-1.)/2.))
[docs]
def _vinet_deriv1(V, params):
"""Vinet first derivative dE/dV."""
# Thank you, Maxima!
E0, B0, B1, V0 = params['e0'], params['b0'], params['b1'], params['v0']
eta = (V/V0)**(1./3.)
ex = np.exp(1.5*(-eta*B1 + B1 + eta - 1))
return (3*eta -3) / eta**2.0 * B0 * ex
[docs]
def _vinet_deriv2(V, params):
"""Vinet second derivative d^2E/dV^2."""
# Thank you, Maxima!
E0, B0, B1, V0 = params['e0'], params['b0'], params['b1'], params['v0']
eta = (V/V0)**(1./3.)
ex = np.exp(1.5*(-eta*B1 + B1 + eta - 1))
return -B0 * (3*eta**2.0*B1 - 3*eta*B1 - 3*eta**2.0 + 5*eta - 4) / \
(2*eta**5 * V0) * ex
[docs]
class MaxDerivException(Exception):
def __init__(self, msg=None):
self.msg = msg
def __str__(self):
return self.msg
[docs]
class EVFunction:
"""Base class for E(V) models, such as the Vinet EOS."""
[docs]
def __init__(self):
self.param_order = ['e0', 'b0', 'b1', 'v0']
[docs]
def evaluate(self, volume, params):
"""Evaluate function for x-axis `volume`.
Parameters
----------
volume : scalar, 1d array
volume per atom [Ang^3]
params : dict
{'e0', 'b0', 'b1', 'v0'} in eV, eV/Ang^3, 1, Ang^3
"""
pass
[docs]
def deriv(self, volume, params, der=None):
"""Calculate derivative of E(V) of order `der`. ``der=1`` is the first
deriv etc.
Parameters
----------
volume : scalar, 1d array
volume per atom [Ang^3]
params : dict
{'e0', 'b0', 'b1', 'v0'} in eV, eV/Ang^3, 1, Ang^3
der : int
derivative order
"""
pass
[docs]
def get_min(self):
pass
[docs]
def __call__(self, volume, params, der=0):
if der == 0:
return self.evaluate(volume, params)
else:
return self.deriv(volume, params, der)
[docs]
def lst2dct(self, lst):
return dict([(k, lst[ii]) for ii,k in enumerate(self.param_order)])
[docs]
def dct2lst(self, dct):
return [dct[k] for k in self.param_order]
[docs]
class Vinet(EVFunction):
"""Vinet EOS model."""
[docs]
def evaluate(self, volume, params):
return _vinet(volume, params)
[docs]
def deriv(self, volume, params, der=None):
if der == 1:
return _vinet_deriv1(volume, params)
elif der == 2:
return _vinet_deriv2(volume, params)
else:
raise MaxDerivException("der %i not supported" %der)
# Before using this a fitfunc in thermo.Gibbs, test it! You may need to
# implement data scaling, as we do in num.PolyFit.
[docs]
class EosFit(Fit1D):
"""E(V) fit class.
Examples
--------
>>> from pwtools.eos import EosFit
>>> from pwtools.constants import eV_by_Ang3_to_GPa
>>> V = linspace(30, 50, 20)
>>> E = (V-40)**2.0 / 50.0 + 30 + rand(len(V)) / 5.0
>>> plot(V, E, 'o')
>>> f = EosFit(V, E)
>>> vv = linspace(V.min(), V.max(), 200)
>>> plot(vv, f(vv), 'r-')
>>> v0 = f.params['v0']
>>> print("compare fit params and values obtained from methods:")
>>> print("V0: %f Ang^3 (%f)" %(v0, f.get_min()))
>>> print("B0: %f GPa (%f)" %(f.params['b0']*eV_by_Ang3_to_GPa, f.bulkmod(v0)))
>>> print("B1: %f " %f.params['b1'])
>>> print("E0: %f eV (%f)" %(f.params['e0'], f(v0)))
>>> print("P0: %f GPa " %f.pressure(v0))
"""
[docs]
def __init__(self, volume, energy, func=Vinet(), splpoints=500):
"""
Parameters
----------
volume : 1d array
volume per atom [Ang^3]
energy : 1d array
total energy per atom [eV]
func : EVFunction instance
splpoints : int
number of spline points for fallback derivative calculation
"""
Fit1D.__init__(self, x=volume, y=energy)
self.func = func
self.energy = energy
self.volume = volume
self.splpoints = splpoints
self.fit()
@lazyprop
def spl(self):
"""Spline thru the fitted E(V) b/c we are too lazy to calculate the
analytic derivative. Fallback."""
# use many points for accurate deriv
vv = np.linspace(self.volume.min(), self.volume.max(), self.splpoints)
return num.Spline(vv, self(vv, der=0), k=5, s=None)
[docs]
def fit(self):
"""Fit E(V) model, fill ``self.params``."""
# Quadratic fit to get an initial guess for the parameters.
# Thanks: https://github.com/materialsproject/pymatgen
# -> pymatgen/io/abinitio/eos.py
a, b, c = np.polyfit(self.volume, self.energy, 2)
v0 = -b/(2*a)
e0 = a*v0**2 + b*v0 + c
b0 = 2*a*v0
b1 = 4 # b1 is usually a small number like 4
if not self.volume.min() < v0 and v0 < self.volume.max():
raise Exception('The minimum volume of a fitted parabola is not in the input volumes')
# need to use lst2dct and dct2lst here to keep the order of parameters
pp0_dct = dict(e0=e0, b0=b0, b1=b1, v0=v0)
target = lambda pp, v: self.energy - self.func(v, self.func.lst2dct(pp))
pp_opt, ierr = leastsq(target,
self.func.dct2lst(pp0_dct),
args=(self.volume,))
self.params = self.func.lst2dct(pp_opt)
[docs]
def __call__(self, volume, der=0):
"""
Parameters
----------
volume : scalar, 1d array
volume per atom [Ang^3]
der : int
derivative order
"""
try:
return self.func(volume, self.params, der=der)
except MaxDerivException:
return self.spl(volume, der=der)
# Fit1D compat
[docs]
def get_min(self):
"""V0 [Ang^3]"""
if 'v0' in self.params:
return self.params['v0']
else:
return super(EosFit, self).get_min()
[docs]
def pressure(self, volume):
"""P(V) [GPa]
Parameters
----------
volume : scalar, 1d array
volume per atom [Ang^3]
"""
return -self(volume, der=1) * eV_by_Ang3_to_GPa
[docs]
def bulkmod(self, volume):
"""B(V) [GPa]
Parameters
----------
volume : scalar, 1d array
volume per atom [Ang^3]
"""
if 'b0' in self.params:
return self.params['b0'] * eV_by_Ang3_to_GPa
else:
return volume * self(volume, der=2) * eV_by_Ang3_to_GPa
[docs]
class ExternEOS(FlexibleGetters):
"""Base class for calling extern EOS-fitting executables. The class
writes an input file, calls the app, loads E(V) fitted data and loads or
calcutates P(V), B(V).
The number N of data points for the returned arrays (fitted curves) are
handled by derived classes.
We have three "representations" of the data:
(a) input data E(V) : self.volume [Ang^3], self.energy [eV]
(b) fitted or calculated points : self.{ev,pv,bv} -- 2d arrays (N,2)
where N is the number of returned fitted points from the fitting app. N
depends on the fitting app. For instance, in ElkEOSFit, you can use
`npoints` to set N.
(c) Splines thru fitted or calculated (N,2) data ev,pv,bv :
self.spl_{ev,pv,bv}.
Attributes
----------
ev, pv, bv, spl_ev, spl_pv, spl_bv, see fit() doc string.
Examples
--------
>>> from pwtools import eos
>>> efit = eos.ElkEOSFit(app='eos.x', energy=ee, volume=vv)
>>> efit.fit()
>>> plot(vv, ee, 'o-', label='E(V) data')
>>> plot(efit.ev[:,0], efit.ev[:,1], label='E(V) fit')
>>> plot(efit.pv[:,0], efit.pv[:,1], label='P=-dE/dV')
>>> plot(efit.ev[:,0], efit.spl_ev(efit.ev[:,0]), label='spline E(V)')
>>> plot(efit.pv[:,0], efit.spl_pv(efit.pv[:,0]), label='spline P(V)')
>>> print "V0={v0} E0={e0} B0={b0} P0={p0}".format(**efit.get_min())
Notes
-----
For derived classes:
Implement _fit(), which sets self.{ev,pv}. `bv` and `spl_bv` are always
calculated from `ev` or `pv` when :meth:`fit` is called, see also
:meth:`calc_bv` and :meth:`set_bv_method`.
"""
# Notes
# -----
# We distinguish between the volume axis for energy ev[:,0], pressure
# pv[:,0] and bulk modulus bv[:,0] b/c, depending on how P=-dE/dV or B(V)
# is calculated, these may have different length. For instance, if the
# pressure is calculated by finite differences, then ev.shape[0] == N,
# pv.shape[0] == N-1 . This is mostly for historic reasons but also b/c
# it's nice to have an array with x-y data right there.
[docs]
def __init__(self, app=None, energy=None, volume=None, dir=None,
bv_method='ev', verbose=True):
"""
Parameters
----------
app : str
name of the executable ([/path/to/]eos.x), make sure that it is on
your PATH or use an absolute path
energy : 1d array [eV]
volume : 1d array [Ang^3]
dir : str
dir where in- and outfiles are written, default is the basename of
"app" (e.g. "eos.x" for app='/path/to/eos.x')
bv_method : str, {'pv', 'ev'}
Based on which quantity should B(V) and minimum properties be
calculated.
pv: based on P(V)
ev: based on E(V)
verbose : bool
print stdout and stderr of fitting tool
"""
warnings.warn("ExternEOS is deprecated, use EosFit instead",
DeprecationWarning)
assert len(energy) == len(volume), ("volume and energy arrays have "
"not the same length")
assert (np.diff(volume) > 0.0).any(), ("volume seems to be wrongly "
"sorted")
self.app = app
self.energy = energy
self.volume = volume
self.app_basename = os.path.basename(self.app)
self.set_bv_method(bv_method)
self.verbose = verbose
if dir is None:
self.dir = self.app_basename
else:
self.dir = dir
if not os.path.exists(self.dir):
os.makedirs(self.dir)
if self.verbose:
print(("After calling the fit() method, find data from '%s' "
"in %s/" %(self.app, self.dir)))
self.infn = os.path.join(self.dir, 'eos.in')
def _fit(self):
"""Fit E-V data (self.energy, self.volume) and set self.ev, self.pv .
This is the interface which derived classes must implement. If pv is
not calculated by the fitting tool, use smth like num.deriv_spl() to
calculate the pressure P=-dE/dV.
"""
self.ev = None
self.pv = None
# user-callable method
[docs]
def fit(self, *args, **kwargs):
"""Fit E-V data (self.energy, self.volume).
After calling fit(), these attrs are available:
| self.ev : 2d array (N,2) [volume [Ang^3], energy E(V) [eV] ]
| self.pv : 2d array (N,2) [volume [Ang^3], pressure P(V) [GPa]]
| self.bv : 2d array (N,2) [volume [Ang^3], bulk modulus B(V) [GPa]]
| self.spl_ev : Spline thru E(V)
| self.spl_pv : Spline thru P(V)
| self.spl_bv : Spline thru B(V)
"""
self._fit(*args, **kwargs)
self.bv = self.calc_bv()
self.try_set_attr('spl_ev')
self.try_set_attr('spl_pv')
self.try_set_attr('spl_bv')
def _get_spl(self, attr_name):
# attr_name : 'ev', 'pv', 'bv'
# spl_attr_name : 'self.spl_{ev,pv,bv}'
spl_attr_name = "spl_%s" %attr_name
if self.is_set_attr(spl_attr_name):
return getattr(self, spl_attr_name)
else:
arr = getattr(self, attr_name)
return num.Spline(arr[:,0], arr[:,1])
[docs]
def get_spl_ev(self):
return self._get_spl('ev')
[docs]
def get_spl_pv(self):
return self._get_spl('pv')
[docs]
def get_spl_bv(self):
return self._get_spl('bv')
[docs]
def set_bv_method(self, bv_method):
"""Set self.bv_method, a.k.a. switch to another bv_method.
Parameters
----------
bv_method : str
'ev', 'pv'
"""
self.bv_method = bv_method
[docs]
def calc_bv(self):
# B = -V*dP/dV
if self.bv_method == 'pv':
self.try_set_attr('spl_pv')
vv = self.pv[:,0]
return np.array([vv, -vv * self.spl_pv(vv, der=1)]).T
# B = V*d^2E/dV^2
elif self.bv_method == 'ev':
self.try_set_attr('spl_ev')
# eV / Ang^3 -> GPa
fac = eV * 1e21 # 160.2176487
vv = self.ev[:,0]
return np.array([vv, vv * self.spl_ev(vv, der=2) * fac]).T
else:
raise Exception("unknown bv_method: '%s'" %bv_method)
[docs]
def get_min(self, behave='new'):
"""
Calculate properites at energy minimum of E(V).
Parameters
----------
behave : str, optional, {'new', 'old'}
Returns
-------
behave = 'new' : return a dict {v0, e0, p0, b0}
volume, energy, pressure, bulk modulus at energy min
behave = 'old' : array of length 4 [v0, e0, b0, p0]
Notes
-----
We have two sources for pressure: (a) The code which calculated E(V),
i.e. usually some ab initio code (PWscf, ...). (b) Calculated pressure
P=-dE/dV from the EOS fit to E(V). If the (a) pressure at the E(V)
minimum is not very close to zero (say ~ 1e-10), then your E-V data is
incorrect. Usually, this is because of poorly converged calculations
(low cufoff / bad basis set, too few k-points).
"""
self.try_set_attr('spl_pv')
self.try_set_attr('spl_ev')
self.try_set_attr('spl_bv')
if self.bv_method == 'pv':
v0 = self.spl_pv.get_root()
elif self.bv_method == 'ev':
v0 = self.spl_ev.get_min()
else:
raise Exception("unknown bv_method: '%s'" %bv_method)
p0 = self.spl_pv(v0)
e0 = self.spl_ev(v0)
b0 = self.spl_bv(v0)
if behave == 'old':
return np.array([v0, e0, p0, b0])
elif behave == 'new':
dct = {}
dct['v0'] = v0
dct['e0'] = e0
dct['p0'] = p0
dct['b0'] = b0
return dct
else:
raise Exception("unknown value for `behave`: %s" %str(behave))
def _call(self, cmd):
"""
Call shell command 'cmd' and merge stdout and stderr.
Use this instead of common.backtick() if fitting tool insists on beeing
very chatty on stderr.
"""
pp = subprocess.Popen(cmd,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
out,err = pp.communicate()
assert err is None, "stderr output is not None"
return out
[docs]
class ElkEOSFit(ExternEOS):
"""eos.x from the Elk [1] and Exciting [2] codes.
[1] http://elk.sourceforge.net/
[2] http://exciting-code.org/
Note that the data produced by eos.x is divided by natoms and that energy
is in Hartree. We remove the normalization and convert Ha -> eV.
self.{ev,bv,pv} all have the same shape[0] b/c we do not use finite
differences for derivatives.
"""
[docs]
def __init__(self, app='eos.x', natoms=1, name='foo', etype=1,
npoints=300, **kwargs):
"""
Parameters
----------
natoms : int
number of atoms in the unit cell, this is (I think) only used
for normalization and can be set to 1 if not needed
name : str
some dummy name for the input file
etype : int
type of EOS to fit (see below)
npoints : int, optional
number of E-V and P-V points of the fitted curves (`nvplt` in
eos.x)
Notes
-----
From the README:
The equations of state currently implemented are:
1. Universal EOS (Vinet P et al., J. Phys.: Condens. Matter 1, p1941 (1989))
2. Murnaghan EOS (Murnaghan F D, Am. J. Math. 49, p235 (1937))
3. Birch-Murnaghan 3rd-order EOS (Birch F, Phys. Rev. 71, p809 (1947))
4. Birch-Murnaghan 4th-order EOS
5. Natural strain 3rd-order EOS (Poirier J-P and Tarantola A, Phys. Earth
Planet Int. 109, p1 (1998))
6. Natural strain 4th-order EOS
7. Cubic polynomial in (V-V0)
See also
--------
:class:`EosFit`
"""
ExternEOS.__init__(self, app=app, **kwargs)
self.name = name
self.natoms = natoms
self.etype = etype
self.npoints = npoints
assert self.npoints >= len(self.volume), ("npoints is < number of E-V "
"input points")
# From the README:
# ----------------
# input file:
#
# cname : name of crystal up to 256 characters
# natoms : number of atoms in unit cell
# etype : equation of state type (see below)
# vplt1, vplt2, nvplt : volume interval over which to plot energy, pressure etc.
# as well as the number of points in the plot
# nevpt : number of energy-volume points to be inputted
# vpt(i) ept(i) : energy-volume points (atomic units)
#
# Note that the input units are atomic - Bohr and Hartree (NOT Rydbergs).
#
# output files:
#
# All units are atomic unless otherwise stated
# EOS parameters written to PARAM.OUT
# Energy-volume per atom at data points written to EVPAP.OUT
# Energy-volume per atom over interval written to EVPAI.OUT
# Pressure(GPa)-volume per atom at data points written to PVPAP.OUT
# Pressure(GPa)-volume per atom over interval written to PVPAI.OUT
# Enthalpy-pressure(GPa) per atom over interval written to HPPAI.OUT
def _fit(self):
# volume[Bohr^3] etot[Ha] for eos.x
volume = self.volume*(Ang**3.0 / Bohr**3.0)
energy = self.energy*(eV / Ha)
data = np.array([volume, energy]).T
infn_txt =\
"""
%s
%i
%i
%f, %f, %i
%i
%s
"""%(self.name,
self.natoms,
self.etype,
volume[0], volume[-1], self.npoints,
len(volume),
common.str_arr(data))
common.file_write(self.infn, infn_txt)
out = common.backtick('cd %s && %s' %(self.dir, self.app_basename))
if self.verbose:
print(out)
print((open(os.path.join(self.dir,'PARAM.OUT')).read()))
# Remove normalization on natoms. See .../eos/output.f90:
# fitev: [volume [Bohr^3] / natoms, energy [Ha] / natoms]
# fitpv: [volume [Bohr^3] / natoms, pressure [GPa]]
fitev = np.loadtxt(os.path.join(self.dir,'EVPAI.OUT')) * self.natoms
# convert energy back to [Ang^3, eV]
fitev[:,0] *= (Bohr**3 / Ang**3)
fitev[:,1] *= (Ha / eV)
self.ev = fitev
fitpv = np.loadtxt(os.path.join(self.dir,'PVPAI.OUT'))
fitpv[:,0] *= (self.natoms * Bohr**3 / Ang**3)
self.pv = fitpv