Calculate the thermal expansion for a Morse Pair potential using the LAMMPS molecular dynamics simulation code. In the following three methods to calculate the thermal expansion are introduced and compared for a Morse Pair Potential for Aluminium.
As a first step the potential is defined for the LAMMPS molecular dynamics simulation code
by specifying the pair_style
and pair_coeff
commands for the Morse Pair Potential
as well as the Aluminium bulk structure:
from ase.build import bulk
import pandas
potential_dataframe = pandas.DataFrame(
{
"Config": [
["pair_style morse/smooth/linear 9.0", "pair_coeff * * 0.5 1.8 2.95"]
],
"Filename": [[]],
"Model": ["Morse"],
"Name": ["Morse"],
"Species": [["Al"]],
}
)
structure = bulk("Al", cubic=True)
The pandas.DataFrame
based format to specify interatomic potentials is the same pylammpsmpi
uses to interface with
the NIST database for interatomic potentials. In comparison to just providing
the pair_style
and pair_coeff
commands, this extended format enables referencing specific files for the interatomic
potentials "Filename": [[]],
as well as the atomic species "Species": [["Al"]],
to enable consistency checks if the
interatomic potential implements all the interactions to simulate a given atomic structure.
Finally, the last step of the preparation before starting the actual calculation is optimizing the interatomic structure.
While for the Morse potential used in this example this is not necessary, it is essential for extending this example to
other interactomic potentials. For the structure optimization the optimize_positions_and_volume()
function is imported
and applied on the ase.atoms.Atoms
bulk structure for Aluminium:
from atomistics.workflows import optimize_positions_and_volume
task_dict = optimize_positions_and_volume(structure=structure)
task_dict
{'optimize_positions_and_volume': Atoms(symbols='Al4', pbc=True, cell=[4.05, 4.05, 4.05])}
It returns a task_dict
with a single task, the optimization of the positions and the volume of the Aluminium structure.
This task is executed with the LAMMPS molecular dynamics simulation code using the
evaluate_with_lammps()
function:
from atomistics.calculators import evaluate_with_lammps
result_dict = evaluate_with_lammps(
task_dict=task_dict,
potential_dataframe=potential_dataframe,
)
structure_opt = result_dict["structure_with_optimized_positions_and_volume"]
structure_opt
[jupyter-pyiron-2datomistics-2djetdbyfr:00796] mca_base_component_repository_open: unable to open mca_btl_openib: librdmacm.so.1: cannot open shared object file: No such file or directory (ignored) /srv/conda/envs/notebook/lib/python3.10/site-packages/pylammpsmpi/wrapper/ase.py:165: UserWarning: Warning: setting upper trangular matrix might slow down the calculation warnings.warn(
Atoms(symbols='Al4', pbc=True, cell=[[4.047310585424964, 2.478262976797941e-16, 2.478262976797941e-16], [0.0, 4.047310585424964, 2.478262976797941e-16], [0.0, 0.0, 4.047310585424964]])
The result_dict
just contains a single element, the ase.atoms.Atoms
structure object with optimized positions and
volume. After this step the preparation is completed and the three different approximations can be compared in the following.
The first approximation to calculate the thermal expansion is based on the Equation of State derived by Moruzzi, V. L. et al..
So in analogy to the previous example of calculating the elastic properties from the Equation of State, the EnergyVolumeCurveWorkflow
is initialized with the default parameters:
from atomistics.workflows import EnergyVolumeCurveWorkflow
workflow_ev = EnergyVolumeCurveWorkflow(
structure=structure_opt.copy(),
num_points=11,
fit_type="birchmurnaghan",
vol_range=0.05,
axes=["x", "y", "z"],
strains=None,
)
structure_dict = workflow_ev.generate_structures()
structure_dict
{'calc_energy': OrderedDict([(0.95, Atoms(symbols='Al4', pbc=True, cell=[[3.9786988461213992, 2.43625040333692e-16, 2.43625040333692e-16], [0.0, 3.9786988461213992, 2.43625040333692e-16], [0.0, 0.0, 3.9786988461213992]])), (0.96, Atoms(symbols='Al4', pbc=True, cell=[[3.992610493736228, 2.4447688306981026e-16, 2.4447688306981026e-16], [0.0, 3.992610493736228, 2.4447688306981026e-16], [0.0, 0.0, 3.992610493736228]])), (0.97, Atoms(symbols='Al4', pbc=True, cell=[[4.00642586504517, 2.4532283058243666e-16, 2.4532283058243666e-16], [0.0, 4.00642586504517, 2.4532283058243666e-16], [0.0, 0.0, 4.00642586504517]])), (0.98, Atoms(symbols='Al4', pbc=True, cell=[[4.020146608667117, 2.461629838203636e-16, 2.461629838203636e-16], [0.0, 4.020146608667117, 2.461629838203636e-16], [0.0, 0.0, 4.020146608667117]])), (0.99, Atoms(symbols='Al4', pbc=True, cell=[[4.033774328510742, 2.469974409946722e-16, 2.469974409946722e-16], [0.0, 4.033774328510742, 2.469974409946722e-16], [0.0, 0.0, 4.033774328510742]])), (1.0, Atoms(symbols='Al4', pbc=True, cell=[[4.047310585424964, 2.478262976797941e-16, 2.478262976797941e-16], [0.0, 4.047310585424964, 2.478262976797941e-16], [0.0, 0.0, 4.047310585424964]])), (1.01, Atoms(symbols='Al4', pbc=True, cell=[[4.060756898772644, 2.486496469098726e-16, 2.486496469098726e-16], [0.0, 4.060756898772644, 2.486496469098726e-16], [0.0, 0.0, 4.060756898772644]])), (1.02, Atoms(symbols='Al4', pbc=True, cell=[[4.074114747931804, 2.494675792706855e-16, 2.494675792706855e-16], [0.0, 4.074114747931804, 2.494675792706855e-16], [0.0, 0.0, 4.074114747931804]])), (1.03, Atoms(symbols='Al4', pbc=True, cell=[[4.087385573728375, 2.5028018298737613e-16, 2.5028018298737613e-16], [0.0, 4.087385573728375, 2.5028018298737613e-16], [0.0, 0.0, 4.087385573728375]])), (1.04, Atoms(symbols='Al4', pbc=True, cell=[[4.100570779804249, 2.51087544008222e-16, 2.51087544008222e-16], [0.0, 4.100570779804249, 2.51087544008222e-16], [0.0, 0.0, 4.100570779804249]])), (1.05, Atoms(symbols='Al4', pbc=True, cell=[[4.113671733924125, 2.518897460846561e-16, 2.518897460846561e-16], [0.0, 4.113671733924125, 2.518897460846561e-16], [0.0, 0.0, 4.113671733924125]]))])}
After the initialization the generate_structures()
function is called to generate the atomistic structures which are
then in the second step evaluated with the LAMMPS molecular dynamics simulation code to derive
the equilibrium properties:
result_dict = evaluate_with_lammps(
task_dict=structure_dict, potential_dataframe=potential_dataframe
)
result_dict
{'energy': {0.95: -14.609207927145926, 0.96: -14.656740101454448, 0.97: -14.692359030099395, 0.98: -14.716883724875528, 0.99: -14.731079276327009, 1.0: -14.735659820057942, 1.01: -14.731295089579728, 1.02: -14.718611862249286, 1.03: -14.698196715842329, 1.04: -14.670598736769112, 1.05: -14.636332030744796}}
While in the previous example the fit of the energy volume curve was used directly, here the output of the fit, in particular the derived equilibrium properties are the input for the Debye model as defined by Moruzzi, V. L. et al.:
structure_opt.get_volume()
66.29787349319821
fit_dict = workflow_ev.analyse_structures(output_dict=result_dict)
fit_dict
{'b_prime_eq': 6.2365371733275845, 'bulkmodul_eq': 216.057292780608, 'volume_eq': 66.29790137569191, 'energy_eq': -14.735658078942949, 'fit_dict': {'fit_type': 'birchmurnaghan', 'least_square_error': array([8.12779273e-07, 2.83453476e-03, 1.45091623e-03, 3.00518393e-05])}, 'energy': [-14.609207927145926, -14.656740101454448, -14.692359030099395, -14.716883724875528, -14.731079276327009, -14.735659820057942, -14.731295089579728, -14.718611862249286, -14.698196715842329, -14.670598736769112, -14.636332030744796], 'volume': [62.98297981853827, 63.645958553470244, 64.30893728840229, 64.97191602333424, 65.63489475826624, 66.29787349319821, 66.96085222813018, 67.62383096306218, 68.28680969799419, 68.94978843292616, 69.61276716785807]}
import numpy as np
workflow_ev.analyse_structures(output_dict=result_dict)
thermal_properties_dict = workflow_ev.get_thermal_properties(
temperatures=np.arange(1, 1500, 50),
output_keys=["temperatures", "volumes"],
)
temperatures_ev, volume_ev = (
thermal_properties_dict["temperatures"],
thermal_properties_dict["volumes"],
)
/srv/conda/envs/notebook/lib/python3.10/site-packages/atomistics/workflows/evcurve/debye.py:80: RuntimeWarning: overflow encountered in exp return xi**3 / (np.exp(xi) - 1)
The output of the Debye model provides the change of the temperature specific optimal volume volume_ev
which can be plotted over the temperature temperatures_ev
to determine the thermal expansion.
While the Moruzzi, V. L. et al. approach based on the Einstein crystal
is limited to a single frequency, the quasi-harmonic model includes the volume dependent free energy. Inside the
atomistics
package the harmonic and quasi-harmonic model are implemented based on an interface to the Phonopy
framework. Still the user interface is still structured in the same three steps of (1) generating structures, (2) evaluating
these structures and (3) fitting the corresponding model. Starting with the initialization of the QuasiHarmonicWorkflow
which combines the PhonopyWorkflow
with the EnergyVolumeCurveWorkflow
:
from atomistics.workflows import QuasiHarmonicWorkflow
from phonopy.units import VaspToTHz
workflow_qh = QuasiHarmonicWorkflow(
structure=structure_opt.copy(),
num_points=11,
vol_range=0.10,
# fit_type='birchmurnaghan',
interaction_range=10,
factor=VaspToTHz,
displacement=0.01,
dos_mesh=20,
primitive_matrix=None,
number_of_snapshots=None,
)
structure_dict = workflow_qh.generate_structures()
structure_dict
{'calc_energy': {0.9: Atoms(symbols='Al108', pbc=True, cell=[[11.7229062192894, 7.178209789078681e-16, 7.178209789078681e-16], [0.0, 11.7229062192894, 7.178209789078681e-16], [0.0, 0.0, 11.7229062192894]]), 0.92: Atoms(symbols='Al108', pbc=True, cell=[[11.80910715486485, 7.230992638996672e-16, 7.230992638996672e-16], [0.0, 11.80910715486485, 7.230992638996672e-16], [0.0, 0.0, 11.80910715486485]]), 0.94: Atoms(symbols='Al108', pbc=True, cell=[[11.894067681419225, 7.283015957446018e-16, 7.283015957446018e-16], [0.0, 11.894067681419225, 7.283015957446018e-16], [0.0, 0.0, 11.894067681419225]]), 0.96: Atoms(symbols='Al108', pbc=True, cell=[[11.977831481208684, 7.334306492094308e-16, 7.334306492094308e-16], [0.0, 11.977831481208684, 7.334306492094308e-16], [0.0, 0.0, 11.977831481208684]]), 0.98: Atoms(symbols='Al108', pbc=True, cell=[[12.060439826001351, 7.384889514610908e-16, 7.384889514610908e-16], [0.0, 12.060439826001351, 7.384889514610908e-16], [0.0, 0.0, 12.060439826001351]]), 1.0: Atoms(symbols='Al108', pbc=True, cell=[[12.141931756274893, 7.434788930393824e-16, 7.434788930393824e-16], [0.0, 12.141931756274893, 7.434788930393824e-16], [0.0, 0.0, 12.141931756274893]]), 1.02: Atoms(symbols='Al108', pbc=True, cell=[[12.222344243795412, 7.484027378120565e-16, 7.484027378120565e-16], [0.0, 12.222344243795412, 7.484027378120565e-16], [0.0, 0.0, 12.222344243795412]]), 1.04: Atoms(symbols='Al108', pbc=True, cell=[[12.301712339412747, 7.53262632024666e-16, 7.53262632024666e-16], [0.0, 12.301712339412747, 7.53262632024666e-16], [0.0, 0.0, 12.301712339412747]]), 1.06: Atoms(symbols='Al108', pbc=True, cell=[[12.38006930767338, 7.580606125432298e-16, 7.580606125432298e-16], [0.0, 12.38006930767338, 7.580606125432298e-16], [0.0, 0.0, 12.38006930767338]]), 1.08: Atoms(symbols='Al108', pbc=True, cell=[[12.457446749652004, 7.627986143754963e-16, 7.627986143754963e-16], [0.0, 12.457446749652004, 7.627986143754963e-16], [0.0, 0.0, 12.457446749652004]]), 1.1: Atoms(symbols='Al108', pbc=True, cell=[[12.533874715230777, 7.674784775460657e-16, 7.674784775460657e-16], [0.0, 12.533874715230777, 7.674784775460657e-16], [0.0, 0.0, 12.533874715230777]])}, 'calc_forces': {(0.9, 0): Atoms(symbols='Al108', pbc=True, cell=[[11.7229062192894, 7.178209789078681e-16, 7.178209789078681e-16], [0.0, 11.7229062192894, 7.178209789078681e-16], [0.0, 0.0, 11.7229062192894]]), (0.92, 0): Atoms(symbols='Al108', pbc=True, cell=[[11.80910715486485, 7.230992638996672e-16, 7.230992638996672e-16], [0.0, 11.80910715486485, 7.230992638996672e-16], [0.0, 0.0, 11.80910715486485]]), (0.94, 0): Atoms(symbols='Al108', pbc=True, cell=[[11.894067681419225, 7.283015957446018e-16, 7.283015957446018e-16], [0.0, 11.894067681419225, 7.283015957446018e-16], [0.0, 0.0, 11.894067681419225]]), (0.96, 0): Atoms(symbols='Al108', pbc=True, cell=[[11.977831481208684, 7.334306492094308e-16, 7.334306492094308e-16], [0.0, 11.977831481208684, 7.334306492094308e-16], [0.0, 0.0, 11.977831481208684]]), (0.98, 0): Atoms(symbols='Al108', pbc=True, cell=[[12.060439826001351, 7.384889514610908e-16, 7.384889514610908e-16], [0.0, 12.060439826001351, 7.384889514610908e-16], [0.0, 0.0, 12.060439826001351]]), (1.0, 0): Atoms(symbols='Al108', pbc=True, cell=[[12.141931756274893, 7.434788930393824e-16, 7.434788930393824e-16], [0.0, 12.141931756274893, 7.434788930393824e-16], [0.0, 0.0, 12.141931756274893]]), (1.02, 0): Atoms(symbols='Al108', pbc=True, cell=[[12.222344243795412, 7.484027378120565e-16, 7.484027378120565e-16], [0.0, 12.222344243795412, 7.484027378120565e-16], [0.0, 0.0, 12.222344243795412]]), (1.04, 0): Atoms(symbols='Al108', pbc=True, cell=[[12.301712339412747, 7.53262632024666e-16, 7.53262632024666e-16], [0.0, 12.301712339412747, 7.53262632024666e-16], [0.0, 0.0, 12.301712339412747]]), (1.06, 0): Atoms(symbols='Al108', pbc=True, cell=[[12.38006930767338, 7.580606125432298e-16, 7.580606125432298e-16], [0.0, 12.38006930767338, 7.580606125432298e-16], [0.0, 0.0, 12.38006930767338]]), (1.08, 0): Atoms(symbols='Al108', pbc=True, cell=[[12.457446749652004, 7.627986143754963e-16, 7.627986143754963e-16], [0.0, 12.457446749652004, 7.627986143754963e-16], [0.0, 0.0, 12.457446749652004]]), (1.1, 0): Atoms(symbols='Al108', pbc=True, cell=[[12.533874715230777, 7.674784775460657e-16, 7.674784775460657e-16], [0.0, 12.533874715230777, 7.674784775460657e-16], [0.0, 0.0, 12.533874715230777]])}}
In contrast to the previous workflows which only used the calc_energy
function of the simulation codes the PhonopyWorkflow
and correspondingly also the QuasiHarmonicWorkflow
require the calculation of the forces calc_forces
in addition to
the calculation of the energy. Still the general steps of the workflow remain the same:
result_dict = evaluate_with_lammps(
task_dict=structure_dict,
potential_dataframe=potential_dataframe,
)
The structure_dict
is evaluated with the LAMMPS molecular dynamics simulation code to
calculate the corresponding energies and forces. The output is not plotted here as the forces for the 108 atom cells
result in 3x108 outputs per cell. Still the structure of the result_dict
again follows the labels of the structure_dict
as explained before. Finally, in the third step the individual free energy curves at the different temperatures are
fitted to determine the equilibrium volume at the given temperature using the analyse_structures()
and get_thermal_properties()
functions:
workflow_qh.analyse_structures(output_dict=result_dict)
thermal_properties_dict_qm = workflow_qh.get_thermal_properties(
temperatures=np.arange(1, 1500, 50),
output_keys=["free_energy", "temperatures", "volumes"],
quantum_mechanical=True,
)
temperatures_qh_qm, volume_qh_qm = (
thermal_properties_dict_qm["temperatures"],
thermal_properties_dict_qm["volumes"],
)
The optimal volume at the different temperatures
is stored in the volume_qh_qm
in analogy to the previous section. Here the extension _qm
indicates that the quantum-mechanical harmonic oszillator is used.
thermal_properties_dict_cl = workflow_qh.get_thermal_properties(
temperatures=np.arange(1, 1500, 50),
output_keys=["free_energy", "temperatures", "volumes"],
quantum_mechanical=False,
)
temperatures_qh_cl, volume_qh_cl = (
thermal_properties_dict_cl["temperatures"],
thermal_properties_dict_cl["volumes"],
)
For the classical harmonic oszillator the resulting volumes are stored as volume_qh_cl
.
Finally, the third and most commonly used method to determine the volume expansion is using a molecular dynamics
calculation. While the atomistics
package already includes a LangevinWorkflow
at this point we use the Nose-Hoover
thermostat implemented in LAMMPS directly via the LAMMPS calculator interface.
from atomistics.calculators import calc_molecular_dynamics_thermal_expansion_with_lammps
structure_md = structure_opt.copy().repeat(11)
result_dict = calc_molecular_dynamics_thermal_expansion_with_lammps(
structure=structure_md, # atomistic structure
potential_dataframe=potential_dataframe, # interatomic potential defined as pandas.DataFrame
Tstart=15, # temperature to for initial velocity distribution
Tstop=1500, # final temperature
Tstep=5, # temperature step
Tdamp=0.1, # temperature damping of the thermostat
run=100, # number of MD steps for each temperature
thermo=100, # print out from the thermostat
timestep=0.001, # time step for molecular dynamics
Pstart=0.0, # initial pressure
Pstop=0.0, # final pressure
Pdamp=1.0, # barostat damping
seed=4928459, # random seed
dist="gaussian", # Gaussian velocity distribution
)
temperature_md_lst, volume_md_lst = result_dict["temperatures"], result_dict["volumes"]
100%|██████████| 298/298 [06:54<00:00, 1.39s/it]
The calc_molecular_dynamics_thermal_expansion_with_lammps()
function defines a loop over a vector of temperatures in
5K steps. For each step 100 molecular dynamics steps are executed before the temperature is again increased by 5K. For
~280 steps with the Morse Pair Potential this takes approximately 5 minutes on a single core. These simulations can be
further accelerated by adding the cores
parameter. The increase in computational cost is on the one hand related to
the large number of force and energy calls and on the other hand to the size of the atomistic structure, as these
simulations are typically executed with >5000 atoms rather than the 4 or 108 atoms in the other approximations. The
volume for the individual temperatures is stored in the volume_md_lst
list.
To visually compare the thermal expansion predicted by the three different approximations, the matplotlib is used to plot the volume over the temperature:
import matplotlib.pyplot as plt
plt.plot(
temperature_md_lst,
np.array(volume_md_lst) / len(structure_md) * len(structure_opt),
label="Molecular Dynamics",
color="C2",
)
plt.plot(temperatures_qh_qm, volume_qh_qm, label="Quasi-Harmonic (qm)", color="C3")
plt.plot(temperatures_qh_cl, volume_qh_cl, label="Quasi-Harmonic (classic)", color="C0")
plt.plot(temperatures_ev, volume_ev, label="Moruzzi Model", color="C1")
plt.axhline(structure_opt.get_volume(), linestyle="--", color="red")
plt.legend()
plt.xlabel("Temperature (K)")
plt.ylabel("Volume ($\AA^3$)")
Text(0, 0.5, 'Volume ($\\AA^3$)')
Both the Moruzzi, V. L. et al. and the quantum mechanical version of the quasi-harmonic approach start at a larger equilibrium volume as they include the zero point vibrations, resulting in an over-prediction of the volume expansion with increasing temperature. The equilibrium volume is indicated by the dashed red line. Finally, the quasi-harmonic approach with the classical harmonic oszillator agrees very well with the thermal expansion calculated from molecular dynamics for this example of using the Morse Pair Potential.