# coding: utf-8
# Copyright (c) Max-Planck-Institut für Eisenforschung GmbH - Computational Materials Design (CM) Department
# Distributed under the terms of "New BSD License", see the LICENSE file.
from __future__ import print_function
from typing import Optional
import matplotlib.pyplot as plt
import numpy as np
from atomistics.workflows.evcurve.debye import DebyeModel
from atomistics.workflows.evcurve.fit import (
EnergyVolumeFit,
fit_leastsq_eos,
fitfunction,
get_error,
)
from atomistics.workflows.evcurve.helper import _strain_axes
from pyiron_base import JobGenerator
from pyiron_atomistics.atomistics.master.parallel import AtomisticParallelMaster
from pyiron_atomistics.atomistics.structure.atoms import Atoms, ase_to_pyiron
__author__ = "Joerg Neugebauer, Jan Janssen"
__copyright__ = (
"Copyright 2021, Max-Planck-Institut für Eisenforschung GmbH - "
"Computational Materials Design (CM) Department"
)
__version__ = "1.0"
__maintainer__ = "Jan Janssen"
__email__ = "janssen@mpie.de"
__status__ = "production"
__date__ = "Sep 1, 2017"
[docs]
class MurnaghanDebyeModel(DebyeModel):
"""
Calculate Thermodynamic Properties based on the Murnaghan output
"""
[docs]
def __init__(self, murnaghan, num_steps=50):
self._murnaghan = murnaghan
fit_dict = self._murnaghan.fit_dict.copy()
fit_dict["volume"] = self._murnaghan["output/volume"]
fit_dict["energy"] = self._murnaghan["output/energy"]
super().__init__(
fit_dict=fit_dict,
masses=self._murnaghan.structure.get_masses(),
num_steps=num_steps,
)
def polynomial(self, poly_fit=None, volumes=None):
if poly_fit is None:
self._murnaghan.fit_polynomial() # TODO: include polyfit in output
poly_fit = self._murnaghan.fit_dict["poly_fit"]
p_fit = np.poly1d(poly_fit)
if volumes is None:
return p_fit(self.volume)
return p_fit(volumes)
@property
def publication(self):
return {
"debye_model": {
"Moruzzi1988": {
"title": "Calculated thermal properties of metals",
"author": ["Moruzzi, V. L.", "Janak, J. F.", "Schwarz, K"],
"journal": "Phys. Rev. B",
"volume": "37",
"issue": "2",
"pages": "790--799",
"numpages": "0",
"month": "jan",
"publisher": "American Physical Society",
"doi": "10.1103/PhysRevB.37.790",
"url": "https://link.aps.org/doi/10.1103/PhysRevB.37.790",
}
}
}
[docs]
class MurnaghanJobGenerator(JobGenerator):
@property
def parameter_list(self):
"""
Returns:
(list)
"""
parameter_lst = []
strains = self._master.input.get("strains")
if strains is None:
strains = np.linspace(
-self._master.input["vol_range"],
self._master.input["vol_range"],
int(self._master.input["num_points"]),
)
for strain in strains:
basis = _strain_axes(
structure=self._master.structure,
axes=self._master.input["axes"],
volume_strain=strain,
)
parameter_lst.append([1 + np.round(strain, 7), basis])
return parameter_lst
[docs]
def job_name(self, parameter):
return "{}_{}".format(self._master.job_name, parameter[0]).replace(".", "_")
[docs]
def modify_job(self, job, parameter):
job.structure = parameter[1]
return job
# ToDo: not all abstract methods implemented
[docs]
class Murnaghan(AtomisticParallelMaster):
"""
Murnghan calculation to obtain the minimum energy volume and bulk modulus.
Example:
>>> pr = Project('my_project')
>>> ref_job = pr.create_job('Lammps', 'lmp')
>>> ref_job.structure = structure_of_your_choice
>>> murn = ref_job.create_job('Murnaghan', 'murn')
>>> murn.run()
The minimum energy volume and bulk modulus are stored in `ref_job['output/equilibrium_volume']`
and `ref_job['output/equilibrium_bulk_modulus/']`.
"""
[docs]
def __init__(self, project, job_name):
"""
Args:
project:
job_name:
"""
super(Murnaghan, self).__init__(project, job_name)
self.__version__ = "0.3.0"
# print ("h5_path: ", self.project_hdf5._h5_path)
# define default input
self.input["num_points"] = (11, "number of sample points")
self.input["fit_type"] = (
"polynomial",
"['polynomial', 'birch', 'birchmurnaghan', 'murnaghan', 'pouriertarantola', 'vinet']",
)
self.input["fit_order"] = (3, "order of the fit polynom")
self.input["vol_range"] = (
0.1,
"relative volume variation around volume defined by ref_ham",
)
self.input["axes"] = (
("x", "y", "z"),
"Axes along which the strain will be applied",
)
self.input["strains"] = (
None,
"List of strains that should be calculated. If given vol_range and num_points take no effect.",
)
self.input["allow_aborted"] = (
0,
"The number of child jobs that are allowed to abort, before the whole job is considered aborted.",
)
self.debye_model = None
self.fit_module = EnergyVolumeFit()
self.fit_dict = None
self._debye_T = None
self._job_generator = MurnaghanJobGenerator(self)
[docs]
def convergence_check(self) -> bool:
"""
Checks if the Murnaghan job has cnverged or not
Note: Currently, a 3rd order polynomial is fit to check if there is any convergence
Returns:
bool: True if the calculation is converged
"""
if super().convergence_check():
e_vol = self["output/equilibrium_volume"]
return e_vol is not None
else:
return False
@property
def fit(self):
if self.debye_model is None and self.fit_dict is None:
raise ValueError(
"The fit object is only available after fitting the energy volume curve."
)
elif self.debye_model is None:
self.debye_model = MurnaghanDebyeModel(self)
return self.debye_model
@property
def equilibrium_volume(self):
return self.fit_dict["volume_eq"]
@property
def equilibrium_energy(self):
return self.fit_dict["energy_eq"]
def fit_polynomial(self, fit_order=3, vol_erg_dic=None):
return self.poly_fit(fit_order=fit_order, vol_erg_dic=vol_erg_dic)
def fit_murnaghan(self, vol_erg_dic=None):
return self._fit_eos_general(vol_erg_dic=vol_erg_dic, fittype="murnaghan")
def fit_birch_murnaghan(self, vol_erg_dic=None):
return self._fit_eos_general(vol_erg_dic=vol_erg_dic, fittype="birchmurnaghan")
def fit_vinet(self, vol_erg_dic=None):
return self._fit_eos_general(vol_erg_dic=vol_erg_dic, fittype="vinet")
def _final_struct_to_hdf(self):
with self._hdf5.open("output") as hdf5:
structure = self.get_structure(frame=-1)
if not isinstance(structure, Atoms):
structure = ase_to_pyiron(structure)
structure.to_hdf(hdf5)
def _store_fit_in_hdf(self, fit_dict):
with self.project_hdf5.open("input") as hdf5_input:
self.input.to_hdf(hdf5_input)
with self.project_hdf5.open("output") as hdf5:
hdf5["equilibrium_energy"] = fit_dict["energy_eq"]
hdf5["equilibrium_volume"] = fit_dict["volume_eq"]
hdf5["equilibrium_bulk_modulus"] = fit_dict["bulkmodul_eq"]
hdf5["equilibrium_b_prime"] = fit_dict["b_prime_eq"]
self._final_struct_to_hdf()
def _fit_eos_general(self, vol_erg_dic=None, fittype="birchmurnaghan"):
self._set_fit_module(vol_erg_dic=vol_erg_dic)
fit_dict = self.fit_module.fit_eos_general(fittype=fittype)
self.input["fit_type"] = fit_dict["fit_type"]
self.input["fit_order"] = 0
self._store_fit_in_hdf(fit_dict=fit_dict)
self.fit_dict = fit_dict
return fit_dict
def _fit_leastsq(self, volume_lst, energy_lst, fittype="birchmurnaghan"):
return fit_leastsq_eos(
volume_lst=volume_lst, energy_lst=energy_lst, fittype=fittype
)
def _set_fit_module(self, vol_erg_dic=None):
if vol_erg_dic is not None:
if "volume" in vol_erg_dic.keys() and "energy" in vol_erg_dic.keys():
self.fit_module = EnergyVolumeFit(
volume_lst=vol_erg_dic["volume"], energy_lst=vol_erg_dic["energy"]
)
else:
raise KeyError
else:
df = self.output_to_pandas()
self.fit_module = EnergyVolumeFit(
volume_lst=df["volume"].values, energy_lst=df["energy"].values
)
def poly_fit(self, fit_order=3, vol_erg_dic=None):
self._set_fit_module(vol_erg_dic=vol_erg_dic)
fit_dict = self.fit_module.fit_polynomial(fit_order=fit_order)
if fit_dict is None:
self._logger.warning("Minimum could not be found!")
else:
self.input["fit_type"] = fit_dict["fit_type"]
self.input["fit_order"] = fit_dict["fit_order"]
self._store_fit_in_hdf(fit_dict=fit_dict)
self.fit_dict = fit_dict
return fit_dict
def list_structures(self):
if self.ref_job.structure is not None:
return [parameter[1] for parameter in self._job_generator.parameter_list]
else:
return []
[docs]
def validate_ready_to_run(self):
axes = self.input["axes"]
if len(set(axes)) != len(axes):
raise ValueError('input["axes"] may not contain duplicate entries!')
if not (1 <= len(axes) <= 3):
raise ValueError('input["axes"] must contain one to three entries!')
if set(axes).union(["x", "y", "z"]) != {"x", "y", "z"}:
raise ValueError('input["axes"] entries must be one of "x", "y" or "z"!')
[docs]
def collect_output(self):
if self.ref_job.server.run_mode.interactive:
ham = self.project_hdf5.inspect(self.child_ids[0])
erg_lst = ham["output/generic/energy_tot"]
vol_lst = ham["output/generic/volume"]
arg_lst = np.argsort(vol_lst)
self._output["volume"] = vol_lst[arg_lst]
self._output["energy"] = erg_lst[arg_lst]
else:
erg_lst, vol_lst, err_lst, id_lst = [], [], [], []
allowed_aborted_children = self.input.get("allow_aborted", 0)
for job_id in self.child_ids:
ham = self.project_hdf5.inspect(job_id)
if ham.status == "aborted":
if allowed_aborted_children == 0:
raise ValueError(f"Child {ham.name}({job_id}) is aborted!")
allowed_aborted_children -= 1
continue
if "energy_tot" in ham["output/generic"].list_nodes():
energy = ham["output/generic/energy_tot"][-1]
elif "energy_pot" in ham["output/generic"].list_nodes():
energy = ham["output/generic/energy_pot"][-1]
else:
raise ValueError("Neither energy_pot or energy_tot was found.")
volume = ham["output/generic/volume"][-1]
erg_lst.append(np.mean(energy))
err_lst.append(np.var(energy))
vol_lst.append(volume)
id_lst.append(job_id)
aborted_children = (
self.input.get("allow_aborted", 0) - allowed_aborted_children
)
if aborted_children > 0:
self.logger.warning(
f"{aborted_children} aborted, but proceeding anyway."
)
vol_lst = np.array(vol_lst)
erg_lst = np.array(erg_lst)
err_lst = np.array(err_lst)
id_lst = np.array(id_lst)
arg_lst = np.argsort(vol_lst)
self._output["volume"] = vol_lst[arg_lst]
self._output["energy"] = erg_lst[arg_lst]
self._output["error"] = err_lst[arg_lst]
self._output["id"] = id_lst[arg_lst]
with self.project_hdf5.open("output") as hdf5_out:
for key, val in self._output.items():
hdf5_out[key] = val
if self.input["fit_type"] == "polynomial":
self.fit_polynomial(fit_order=self.input["fit_order"])
else:
self._fit_eos_general(fittype=self.input["fit_type"])
[docs]
def plot(
self,
per_atom: bool = False,
num_steps: int = 100,
plt_show: bool = True,
ax=None,
plot_kwargs: Optional[dict] = None,
):
"""
Plot E-V curve.
Args:
per_atom (optional, bool): normalize energy and volume by number of atoms in structure before plotting
num_steps (optional, int): number of sample points to interpolate the calculated values on
plt_show (optional, bool): call `matplotlib.pyplot.show()` after plotting (only necessary when running pyiron from scripts)
ax (optional, plt.Axes): if given plot onto this axis, otherwise create new figure for the plot
plot_kwargs (optional, dict): arguments passed verbatim to `matplotlib.pyplot.plot()`
Returns:
ax: The axis plotted onto
Raises:
ValueError: if job is not finished when calling this method
"""
if not (self.status.finished or self.status.not_converged):
raise ValueError(
"Job must have finished executing before calling this method."
)
if ax is None:
ax = plt.subplot(111)
else:
plt_show = False
if not self.fit_dict:
if self.input["fit_type"] == "polynomial":
self.fit_polynomial(fit_order=self.input["fit_order"])
else:
self._fit_eos_general(fittype=self.input["fit_type"])
df = self.output_to_pandas()
vol_lst, erg_lst = df["volume"].values, df["energy"].values
x_i = np.linspace(np.min(vol_lst), np.max(vol_lst), num_steps)
if plot_kwargs is None:
plot_kwargs = {}
if "color" in plot_kwargs.keys():
color = plot_kwargs["color"]
del plot_kwargs["color"]
else:
color = "blue"
if "marker" in plot_kwargs.keys():
del plot_kwargs["marker"]
if "label" in plot_kwargs.keys():
label = plot_kwargs["label"]
del plot_kwargs["label"]
else:
label = self.input["fit_type"]
normalization = 1 if not per_atom else len(self.structure)
if self.fit_dict is not None:
if self.input["fit_type"] == "polynomial":
p_fit = np.poly1d(self.fit_dict["poly_fit"])
least_square_error = get_error(vol_lst, erg_lst, p_fit)
ax.set_title("Murnaghan: error: " + str(least_square_error))
ax.plot(
x_i / normalization,
p_fit(x_i) / normalization,
"-",
label=label,
color=color,
linewidth=3,
**plot_kwargs,
)
else:
V0 = self.fit_dict["volume_eq"]
E0 = self.fit_dict["energy_eq"]
B0 = self.fit_dict["bulkmodul_eq"]
BP = self.fit_dict["b_prime_eq"]
eng_fit_lst = fitfunction(
parameters=[E0, B0, BP, V0], vol=x_i, fittype=self.input["fit_type"]
)
ax.plot(
x_i / normalization,
eng_fit_lst / normalization,
"-",
label=label,
color=color,
linewidth=3,
**plot_kwargs,
)
ax.plot(
vol_lst / normalization,
erg_lst / normalization,
"x",
color=color,
markersize=20,
**plot_kwargs,
)
ax.legend()
ax.set_xlabel("Volume ($\AA^3$)")
ax.set_ylabel("energy (eV)")
if plt_show:
plt.show()
return ax
def _get_structure(self, frame=-1, wrap_atoms=True):
"""
Gives original structure or final one with equilibrium volume.
Args:
iteration_step (int): if 0 return original structure; if 1/-1 structure with equilibrium volume
Returns:
:class:`pyiron_atomistics.atomistics.structure.atoms.Atoms`: requested structure
"""
if frame == 1:
old_vol = self.structure.get_volume()
new_vol = self["output/equilibrium_volume"]
vol_strain = new_vol / old_vol - 1
return _strain_axes(
structure=self.structure,
axes=self.input["axes"],
volume_strain=vol_strain,
)
elif frame == 0:
return self.structure
def _number_of_structures(self):
if self.structure is None:
return 0
elif not self.status.finished:
return 1
else:
return 2