Module pyms.structure_routines
The structures module.
A collection of functions and classes for reading in and manipulating structures and creating potential arrays for multislice simulation.
Expand source code
"""
The structures module.
A collection of functions and classes for reading in and manipulating structures
and creating potential arrays for multislice simulation.
"""
import itertools
import ase
import numpy as np
import torch
import matplotlib.pyplot as plt
import copy
from re import split, match
from os.path import splitext
from .atomic_scattering_params import e_scattering_factors, atomic_symbol
from .Probe import wavev, relativistic_mass_correction
from .utils.numpy_utils import bandwidth_limit_array, q_space_array, ensure_array
from .utils.torch_utils import sinc, torch_c_exp, get_device, cx_from_numpy
def remove_common_factors(nums):
"""Remove common divisible factors from a set of numbers."""
nums = np.asarray(nums, dtype=np.int)
g_ = np.gcd.reduce(nums)
while g_ > 1:
nums //= g_
g_ = np.gcd.reduce(nums)
return nums
def psuedo_rational_tiling(dim1, dim2, EPS):
"""
Calculate the psuedo-rational tiling for matching objects of different dimensions.
For two dimensions, dim1 and dim2, work out the multiplicative
tiling so that those dimensions might be matched to within error EPS.
"""
if np.any([dim1 == 0, dim2 == 0]):
return 1, 1
tile1 = int(np.round(np.abs(dim2 / dim1) / EPS))
tile2 = int(np.round(1 / EPS))
return remove_common_factors([tile1, tile2])
def Xray_scattering_factor(Z, gsq, units="A"):
"""
Calculate the X-ray scattering factor for atom with atomic number Z.
Parameters
----------
Z : int
Atomic number of atom of interest.
gsq : float or array_like
Reciprocal space value(s) in Angstrom squared at which to evaluate the
X-ray scattering factor.
units : string, optional
Units in which to calculate X-ray scattering factor, can be 'A' for
Angstrom, or 'VA' for volt-Angstrom.
"""
# Bohr radius in Angstrom
a0 = 0.529177
# gsq = g**2
return Z - 2 * np.pi ** 2 * a0 * gsq * electron_scattering_factor(
Z, gsq, units=units
)
def electron_scattering_factor(Z, gsq, units="VA"):
"""
Calculate the electron scattering factor for atom with atomic number Z.
Parameters
----------
Z : int
Atomic number of atom of interest.
gsq : float or array_like
Reciprocal space value(s) in Angstrom squared at which to evaluate the
electron scattering factor.
units : string, optional
Units in which to calculate electron scattering factor, can be 'A' for
Angstrom, or 'VA' for volt-Angstrom.
"""
ai = e_scattering_factors[Z - 1, 0:10:2]
bi = e_scattering_factors[Z - 1, 1:10:2]
# Planck's constant in kg Angstrom/s
h = 6.62607004e-24
# Electron rest mass in kg
me = 9.10938356e-31
# Electron charge in Coulomb
qe = 1.60217662e-19
fe = np.zeros_like(gsq)
for i in range(5):
fe += ai[i] * (2 + bi[i] * gsq) / (1 + bi[i] * gsq) ** 2
# Result can be returned in units of Volt Angstrom ('VA') or Angstrom ('A')
if units == "VA":
return h ** 2 / (2 * np.pi * me * qe) * fe
elif units == "A":
return fe
def calculate_scattering_factors(gridshape, gridsize, elements):
"""Calculate the electron scattering factors on a reciprocal space grid.
Parameters
----------
gridshape : (2,) array_like
pixel size of the grid
gridsize : (2,) array_like
Lateral real space sizing of the grid in Angstrom
elements : (M,) array_like
List of elements for which electron scattering factors are required
Returns
-------
fe : (M, *gridshape)
Array of electron scattering factors in reciprocal space for each
element
"""
# Get reciprocal space array
g = q_space_array(gridshape, gridsize)
gsq = np.square(g[0]) + np.square(g[1])
# Initialise scattering factor array
fe = np.zeros((len(elements), *gridshape), dtype=np.float32)
# Loop over unique elements
for ielement, element in enumerate(elements):
fe[ielement, :, :] = electron_scattering_factor(element, gsq)
return fe
def find_equivalent_sites(positions, EPS=1e-3):
"""Find equivalent atomic sites in a list of atomic positions object.
This function is used to detect two atoms sharing the same postions (are
with EPS of each other) with fractional occupancy, and return an index of
these equivalent sites.
"""
# Import the pair-wise distance function from scipy
from scipy.spatial.distance import pdist
natoms = positions.shape[0]
# Calculate pairwise distance between each atomic site
distance_matrix = pdist(positions)
# Initialize index of equivalent sites (initially assume all sites are
# independent)
equivalent_sites = np.arange(natoms, dtype=np.int)
# Find equivalent sites
equiv = distance_matrix < EPS
# If there are equivalent sites correct the index of equivalent sites
if np.any(equiv):
# Masking function to get indices from distance_matrix
iu = np.mask_indices(natoms, np.triu, 1)
# Get a list of equivalent sites
sites = np.nonzero(equiv)[0]
for site in sites:
# Use the masking function to
equivalent_sites[iu[1][site]] = iu[0][site]
return equivalent_sites
def interaction_constant(E, units="rad/VA"):
"""
Calculate the electron interaction constant, sigma.
The electron interaction constant converts electrostatic potential (in V
Angstrom) to radians. Units of this constant are rad/(V Angstrom). See
Eq. (2.5) in Kirkland's Advanced Computing in electron microscopy.
"""
# Planck's constant in kg Angstrom /s
h = 6.62607004e-24
# Electron rest mass in kg
me = 9.10938356e-31
# Electron charge in Coulomb
qe = 1.60217662e-19
# Electron wave number (reciprocal of wavelength) in Angstrom
k0 = wavev(E)
# Relativistic electron mass correction
gamma = relativistic_mass_correction(E)
if units == "rad/VA":
return 2 * np.pi * gamma * me * qe / k0 / h / h
elif units == "rad/A":
return gamma / k0
def change_of_basis(coords, newuc, olduc):
"""Change of basis for structure unit cell."""
return np.mod(coords[:, :3] @ olduc @ np.linalg.inv(newuc), 1.0)
def rot_matrix(theta, u=np.asarray([0, 0, 1], dtype=np.float)):
"""
Generate a 3D rotational matrix.
Parameters
----------
theta : float
Angle of rotation in radians
u : (3,) array_like
Axis of rotation
"""
from numpy import sin, cos
c = cos(theta)
s = sin(theta)
ux, uy, uz = u / np.linalg.norm(u)
R = np.zeros((3, 3))
R[0, :] = [
c + ux * ux * (1 - c),
ux * uy * (1 - c) - uz * s,
ux * uz * (1 - c) + uy * s,
]
R[1, :] = [
uy * uz * (1 - c) + uz * s,
c + uy * uy * (1 - c),
uy * uz * (1 - c) - ux * s,
]
R[2, :] = [
uz * ux * (1 - c) - uy * s,
uz * uy * (1 - c) + ux * s,
c + uz * uz * (1 - c),
]
return R
class structure:
"""
Class for simulation objects.
Elements in a structure object:
unitcell :
An array containing the side lengths of the orthorhombic unit cell
atoms :
An array of dimensions total number of atoms by 6 which for each atom
contains the fractional cooordinates within the unit cell for each atom
in the first three entries, the atomic number in the fourth entry,
the atomic occupancy (not yet implemented in the multislice) in the
fifth entry and mean squared atomic displacement in the sixth entry
Title :
Short description of the object of output purposes
"""
def __init__(self, unitcell, atoms, dwf, occ=None, Title="", EPS=1e-2):
"""Initialize a simulation object with necessary variables."""
self.unitcell = np.asarray(unitcell)
natoms = np.asarray(atoms).shape[0]
if occ is None:
occ = np.ones(natoms)
self.atoms = np.concatenate(
[atoms, occ.reshape(natoms, 1), np.asarray(dwf).reshape(natoms, 1)], axis=1
)
self.Title = Title
# Up till now unitcell can be a 3 x 3 matrix with rows describing the
# unit cell edges. If this is the case we need to make sure that the
# unit cell is orthorhombic and find an orthorhombic tiling if this it
# is not orthorhombic
if self.unitcell.ndim > 1:
# Check to see if unit cell is orthorhombic
ortho = np.abs(np.sum(self.unitcell) - np.trace(self.unitcell)) < EPS
if ortho:
# If unit cell is orthorhombic then extract unit cell
# dimension
self.unitcell = np.diag(self.unitcell)
else:
# If not orthorhombic attempt psuedo rational tiling
self.orthorhombic_supercell(EPS=EPS)
# Check if there is any fractional occupancy of atom sites in
# the sample
self.fractional_occupancy = np.any(np.abs(self.atoms[:, 4] - 1.0) > 1e-3)
@classmethod
def fromfile(
cls,
fnam,
temperature_factor_units="ums",
atomic_coordinates="fractional",
EPS=1e-2,
T=None,
):
"""
Read in a simulation object from a structure file.
Appropriate structure files include *.p1 files, which is outputted by
the vesta software:
K. Momma and F. Izumi, "VESTA 3 for three-dimensional visualization of
crystal, volumetric and morphology data," J. Appl. Crystallogr., 44,
1272-1276 (2011).
or a *.xyz file in the standard of the prismatic software
Parameters
----------
fnam : string
Filepath of the structure file
temperature_factor_units : string,optional
Units of the Debye-Waller temperature factors in the structure file
appropriate inputs are B (crystallographic temperature factor),
urms (root mean squared displacement) and ums (mean squared
displacement, the default)
atomic_coordinates : string, optional
Units of the atomic coordinates can be "fractional" or "cartesian"
EPS : float,optional
Tolerance for procedures such as psuedo-rational tiling for
non-orthorhombic crystal unit cells
T : (3,3) array_like or None
An optional transformation matrix to be applied to the unit cell
and the atomic coordinates
"""
f = open(fnam, "r")
ext = splitext(fnam)[1].lower()
# Read title
Title = f.readline().strip()
if ext == ".p1":
# I have no idea what the second line in the p1 file format means
# so ignore it
f.readline()
# Get unit cell vector - WARNING assume an orthorhombic unit cell
unitcell = np.loadtxt(f, max_rows=3, dtype=np.float)
# Get the atomic symbol of each element
atomtypes = np.loadtxt(f, max_rows=1, dtype=str, ndmin=1) # noqa
# Get the number of atoms of each type
natoms = np.loadtxt(f, max_rows=1, dtype=int, ndmin=1)
# Skip empty line
f.readline()
# Total number of atoms
totnatoms = np.sum(natoms)
# Intialize array containing atomic information
atoms = np.zeros((totnatoms, 6))
dwf = np.zeros((totnatoms,))
occ = np.zeros((totnatoms,))
for i in range(totnatoms):
atominfo = split(r"\s+", f.readline().strip())[:6]
# First three entries are the atomic coordinates
atoms[i, :3] = np.asarray(atominfo[:3], dtype=np.float)
# Fourth entry is the atomic symbol
atoms[i, 3] = atomic_symbol.index(
match("([A-Za-z]+)", atominfo[3]).group(0)
)
# Final entries are the fractional occupancy and the temperature
# (Debye-Waller) factor
occ[i] = atominfo[4]
dwf[i] = atominfo[5]
elif ext == ".xyz":
# Read in unit cell dimensions
unitcell = np.asarray(
[float(x) for x in split(r"\s+", f.readline().strip())[:3]]
)
atoms = []
for line in f:
# Look for end of file marker
if line.strip() == "-1":
break
# Otherwise parse line
atoms.append(
np.array([float(x) for x in split(r"\s+", line.strip())[:6]])
)
# Now stack all atoms into numpy array
atoms_ = np.stack(atoms, axis=0)
# Rearrange columns of numpy array to match standard
totnatoms = atoms_.shape[0]
atoms = np.zeros((totnatoms, 4))
# Atomic coordinates
atoms[:, :3] = atoms_[:, 1:4]
# Atomic numbers (Z)
atoms[:, 3] = atoms_[:, 0]
# Fractional occupancy and Debye-Waller (temperature) factor
dwf = atoms_[:, 5]
occ = atoms_[:, 4]
else:
print("File extension: {0} not recognized".format(ext))
return None
# Close file
f.close()
# If temperature factors are given in any other format than mean square
# (ums) convert to mean square. Acceptable formats are crystallographic
# temperature factor B and root mean square (urms) displacements
if temperature_factor_units == "B":
dwf *= 1 / (8 * np.pi ** 2)
elif temperature_factor_units == "urms":
dwf = dwf ** 2
elif temperature_factor_units == "ums":
pass
else:
raise ValueError("Unrecognized temperature factor units")
# If necessary, Convert atomic positions to fractional coordinates
if atomic_coordinates == "cartesian":
atoms[:, :3] /= unitcell[:3][np.newaxis, :]
atoms[:, :3] = atoms[:, :3] % 1.0
if T is not None:
# Transform atoms to cartesian basis and then apply transformation
# matrix
atoms[:, :3] = (T @ unitcell @ atoms[:, :3].T).T
# Apply transformation matrix to unit-cell
unitcell = unitcell @ T.T
# Apply inverse of unit cell
atoms[:, :3] = (np.linalg.inv(unitcell) @ atoms[:, :3].T).T
return cls(unitcell, atoms[:, :4], dwf, occ, Title, EPS=EPS)
@classmethod
def from_ase_cluster(cls, asecell, occupancy=None, Title="", dwf=None):
"""Initialize from Atomic Simulation Environment (ASE) cluster object."""
unitcell = asecell.cell[:]
natoms = asecell.numbers.shape[0]
atoms = np.concatenate(
[
asecell.cell.scaled_positions(asecell.positions),
asecell.numbers.reshape(natoms, 1),
],
axis=1,
)
if occupancy is None:
occ = np.ones(natoms)
if dwf is None:
dwf = np.ones(natoms) * 3 / np.pi ** 2 / 8
return cls(unitcell, atoms, dwf, occ, Title)
def to_ase_atoms(self):
"""Convert structure to Atomic Simulation Environment (ASE) atoms object."""
scaled_positions = self.atoms[:, :3]
numbers = self.atoms[:, 3].astype(np.int)
cell = self.unitcell
pbc = [True, True, True]
return ase.Atoms(
scaled_positions=scaled_positions, numbers=numbers, cell=cell, pbc=pbc
)
def orthorhombic_supercell(self, EPS=1e-2):
"""
Create an orthorhombic supercell from a monoclinic crystal unit cell.
If not orthorhombic attempt psuedo rational tiling of general
monoclinic structure. Assumes that the self.unitcell matrix is lower
triangular.
"""
if not np.abs(np.dot(self.unitcell[0], self.unitcell[1])) < EPS:
tiley, tilex = psuedo_rational_tiling(*self.unitcell[0:2, 0], EPS)
# Make deepcopy of old unit cell
olduc = copy.deepcopy(self.unitcell)
# Tile out atoms
self.tile(tiley, tilex, 1)
# Calculate size of old unit cell under tiling
olduc = np.asarray([tiley, tilex, 1])[:, np.newaxis] * olduc
self.unitcell = copy.deepcopy(olduc)
self.unitcell[1, 0] = 0.0
# Now calculate fractional coordinates in new orthorhombic cell
self.atoms[:, :3] = change_of_basis(self.atoms[:, :3], self.unitcell, olduc)
else:
self.unitcell[0, 1:] = 0.0
self.unitcell[1, ::2] = 0.0
# Now tile crystal in x and y
tilez1, tiley = psuedo_rational_tiling(*self.unitcell[::-2, 0], EPS)
tilez2, tilex = psuedo_rational_tiling(*self.unitcell[3:0:-1, 1], EPS)
tilez = remove_common_factors([tilez1, tilez2, tilez1 * tilez2])[-1]
tiley *= tilez // tilez1
tilex *= tilez // tilez2
olduc = copy.deepcopy(self.unitcell)
# Tile out atoms
self.tile(tiley, tilex, tilez)
# Calculate size of old unit cell under tiling
olduc = np.asarray([tiley, tilex, tilez])[:, np.newaxis] * olduc
self.unitcell = copy.deepcopy(olduc)
self.unitcell[2, 0:2] = 0.0
# Now calculate fractional coordinates in new orthorhombic cell
self.atoms[:, :3] = np.mod(
self.atoms[:, :3] @ olduc @ np.linalg.inv(self.unitcell), 1.0
)
self.unitcell = np.diag(self.unitcell)
# Check for negative values of self.unitcell and rectify
for i in range(3):
if self.unitcell[i] < 0:
self.atoms[:, i] = (1.0 - self.atoms[:, i]) % 1.0
self.unitcell = np.abs(self.unitcell)
def quickplot(
self, atomscale=None, cmap=plt.get_cmap("Dark2"), block=True, colors=None
):
"""
Make a quick 3D scatter plot of the atomic sites within the structure.
For more detailed visualization output the structure file to a file format
readable by the Vesta software using output_vesta_xtl
"""
from mpl_toolkits.mplot3d import Axes3D # NOQA
if atomscale is None:
atomscale = 1e-3 * np.amax(self.unitcell)
fig = plt.figure()
ax = fig.add_subplot(111, projection="3d")
if colors is None:
colors = cmap(self.atoms[:, 3] / np.amax(self.atoms[:, 3]))
sizes = self.atoms[:, 3] * atomscale
ax.scatter(
*[self.atoms[:, i] * self.unitcell[i] for i in [1, 0, 2]], c=colors, s=sizes
)
ax.set_xlim3d(0.0, self.unitcell[1])
ax.set_ylim3d(top=0.0, bottom=self.unitcell[0])
ax.set_zlim3d(top=0.0, bottom=self.unitcell[2])
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_zlabel("z")
plt.show(block=block)
return fig
def output_vesta_xtl(self, fnam):
"""Output an .xtl file which is viewable by the vesta software.
See K. Momma and F. Izumi, "VESTA 3 for three-dimensional visualization
of crystal, volumetric and morphology data," J. Appl. Crystallogr., 44,
1272-1276 (2011).
Warning: Vesta xtl files do not contain fractional occupancy information
"""
f = open(splitext(fnam)[0] + ".xtl", "w")
f.write("TITLE " + self.Title + "\n CELL \n")
f.write(" {0:.5f} {1:.5f} {2:.5f} 90 90 90\n".format(*self.unitcell))
f.write("SYMMETRY NUMBER 1\n SYMMETRY LABEL P1\n ATOMS \n")
f.write("NAME X Y Z" + "\n")
for i in range(self.atoms.shape[0]):
f.write(
"{0} {1:.4f} {2:.4f} {3:.4f}\n".format(
atomic_symbol[int(self.atoms[i, 3])], *self.atoms[i, :3]
)
)
f.write("EOF")
f.close()
def output_xyz(
self, fnam, atomic_coordinates="cartesian", temperature_factor_units="sqrturms"
):
"""
Output an .xyz structure file.
This is the input format used by Kirkland's EM codes and the prismatic
software.
"""
f = open(splitext(fnam)[0] + ".xyz", "w")
f.write(self.Title + "\n {0:.4f} {1:.4f} {2:.4f}\n".format(*self.unitcell))
if atomic_coordinates == "cartesian":
coords = self.atoms[:, :3] * self.unitcell
else:
coords = self.atoms[:, :3]
# If temperature factors are given as B then convert to urms
if temperature_factor_units == "B":
DWFs = self.atoms[:, 5] * 8 * np.pi ** 2
elif temperature_factor_units == "sqrturms":
DWFs = np.sqrt(self.atoms[:, 5])
for coord, atom, DWF in zip(coords, self.atoms, DWFs):
f.write(
"{0:d} {1:.4f} {2:.4f} {3:.4f} {4:.2f} {5:.3f}\n".format(
int(atom[3]), *coord, atom[4], DWF
)
)
f.write("-1")
f.close()
def make_potential(
self,
pixels,
subslices=[1.0],
tiling=[1, 1],
displacements=True,
fractional_occupancy=True,
fe=None,
device=None,
dtype=torch.float32,
seed=None,
):
"""
Generate the projected potential of the structure.
Calculate the projected electrostatic potential for a structure on a
pixel grid with dimensions specified by pixels. Subslicing the unit
cell is achieved by passing an array subslices that contains as its
entries the depths at which each subslice should be terminated in units
of fractional coordinates. Tiling of the unit cell (often necessary to
make a sufficiently large simulation grid to fit the probe) is achieved
by passing the tiling factors in the array tiling.
Parameters
----------
pixels: int, (2,) array_like
The pixel size of the grid on which to calculate the projected
potentials
subslices: float, array_like, optional
An array containing the depths at which each slice ends as a fraction
of the simulation unit-cell
tiling: int, (2,) array_like, optional
Tiling of the simulation object (often necessary to make a
sufficiently large simulation grid to fit the probe)
displacements: bool, optional
Pass displacements = False to turn off random displacements of the
atoms due to thermal motion
fractional_occupancy: bool, optional
Pass fractional_occupancy = False to turn off fractional occupancy
of atomic sites
fe: float, array_like
An array containing the electron scattering factors for the elements
in the structure as calculated by the function
calculate_scattering_factors, can be passed to save recalculating
each time new potentials are generated
device: torch.device
Allows the user to control which device the calculations will occur
on
dtype: torch.dtype
Controls the data-type of the output
seed: int
Seed for random number generator for atomic displacements.
"""
# Initialize device cuda if available, CPU if no cuda is available
device = get_device(device)
# Ensure pixels is integer
pixels_ = [int(x) for x in pixels]
# Seed random number generator for displacements
if seed is not None:
torch.manual_seed(seed)
tiling_ = np.asarray(tiling[:2])
gsize = np.asarray(self.unitcell[:2]) * tiling_
psize = np.asarray(pixels_)
pixperA = np.asarray(pixels_) / np.asarray(self.unitcell[:2]) / tiling_
# Get a list of unique atomic elements
elements = list(set(np.asarray(self.atoms[:, 3], dtype=np.int)))
# Get number of unique atomic elements
nelements = len(elements)
nsubslices = len(subslices)
# Build list of equivalent sites if Fractional occupancy is to be
# taken into account
if fractional_occupancy and self.fractional_occupancy:
equivalent_sites = find_equivalent_sites(self.atoms[:, :3], EPS=1e-3)
# FDES method
# Intialize potential array
P = torch.zeros(
np.prod([nelements, nsubslices, *pixels_, 2]), device=device, dtype=dtype
)
# Construct a map of which atom corresponds to which slice
islice = np.zeros((self.atoms.shape[0]), dtype=np.int)
slice_stride = np.prod(pixels_) * 2
# if nsubslices > 1:
# Finds which slice atom can be found in
# WARNING Assumes that the slices list ends with 1.0 and is in
# ascending order
for i in range(nsubslices):
zmin = 0 if i == 0 else subslices[i - 1]
atoms_in_slice = (self.atoms[:, 2] % 1.0 >= zmin) & (
self.atoms[:, 2] % 1.0 < subslices[i]
)
islice[atoms_in_slice] = i * slice_stride
islice = torch.from_numpy(islice).type(torch.long).to(device)
# else:
# islice = 0
# Make map a pytorch Tensor
# Construct a map of which atom corresponds to which element
element_stride = nsubslices * slice_stride
ielement = torch.tensor(
[
element_stride * elements.index(int(self.atoms[iatom, 3]))
for iatom in range(self.atoms.shape[0])
],
dtype=torch.long,
device=device,
)
if displacements:
# Generate thermal displacements
urms = torch.tensor(
np.sqrt(self.atoms[:, 5])[:, np.newaxis] * pixperA[np.newaxis, :],
dtype=P.dtype,
device=device,
).view(self.atoms.shape[0], 2)
# FDES algorithm implemented using the pytorch scatter_add function,
# which takes a list of numbers and adds them to a corresponding list
# of coordinates
for tile in range(tiling[0] * tiling[1]):
# For these atomic coordinates (in fractional coordinates) convert
# to pixel coordinates
posn = (
(
self.atoms[:, :2]
+ np.asarray([tile % tiling[0], tile // tiling[0]])[np.newaxis, :]
)
/ tiling_
* psize
)
posn = torch.from_numpy(posn).to(device).type(P.dtype)
if displacements:
# Add displacement sampled from normal distribution to account
# for atomic thermal motion
disp = (
torch.randn(self.atoms.shape[0], 2, dtype=P.dtype, device=device)
* urms
)
# If using fractional occupancy force atoms occupying equivalent
# sites to have the same displacement
if fractional_occupancy and self.fractional_occupancy:
disp = disp[equivalent_sites, :]
posn[:, :2] += disp
yc = (
torch.remainder(torch.ceil(posn[:, 0]).type(torch.long), pixels_[0])
* pixels_[1]
* 2
)
yf = (
torch.remainder(torch.floor(posn[:, 0]).type(torch.long), pixels_[0])
* pixels_[1]
* 2
)
xc = (
torch.remainder(torch.ceil(posn[:, 1]).type(torch.long), pixels_[1]) * 2
)
xf = (
torch.remainder(torch.floor(posn[:, 1]).type(torch.long), pixels_[1])
* 2
)
yh = torch.remainder(posn[:, 0], 1.0)
yl = 1.0 - yh
xh = torch.remainder(posn[:, 1], 1.0)
xl = 1.0 - xh
# Account for fractional occupancy of atomic sites if requested
if fractional_occupancy and self.fractional_occupancy:
xh *= torch.from_numpy(self.atoms[:, 4]).type(P.dtype).to(device)
xl *= torch.from_numpy(self.atoms[:, 4]).type(P.dtype).to(device)
# Each pixel is set to the overlap of a shifted rectangle in that pixel
P.scatter_add_(0, ielement + islice + yc + xc, yh * xh)
P.scatter_add_(0, ielement + islice + yc + xf, yh * xl)
P.scatter_add_(0, ielement + islice + yf + xc, yl * xh)
P.scatter_add_(0, ielement + islice + yf + xf, yl * xl)
# Now view potential as a 4D array for next bit
P = P.view(nelements, nsubslices, *pixels_, 2)
# FFT potential to reciprocal space
for i in range(P.shape[0]):
for j in range(P.shape[1]):
P[i, j] = torch.fft(P[i, j], signal_ndim=2)
# Make sinc functions with appropriate singleton dimensions for pytorch
# broadcasting /gridsize[0]*pixels_[0] /gridsize[1]*pixels_[1]
sincy = (
sinc(torch.from_numpy(np.fft.fftfreq(pixels_[0])))
.view([1, 1, pixels_[0], 1, 1])
.to(device)
.type(P.dtype)
)
sincx = (
sinc(torch.from_numpy(np.fft.fftfreq(pixels_[1])))
.view([1, 1, 1, pixels_[1], 1])
.to(device)
.type(P.dtype)
)
# #Divide by sinc functions
P /= sincy
P /= sincx
# Option to precalculate scattering factors and pass to program which
# saves computation for
if fe is None:
fe_ = calculate_scattering_factors(psize, gsize, elements)
else:
fe_ = fe
# Convolve with electron scattering factors using Fourier convolution theorem
P *= torch.from_numpy(fe_).view(nelements, 1, *pixels_, 1).to(device)
norm = np.prod(pixels_) / np.prod(self.unitcell[:2]) / np.prod(tiling)
# Add atoms together
P = norm * torch.sum(P, dim=0)
# Only return real part
return torch.ifft(P, signal_ndim=2)[..., 0]
def make_transmission_functions(
self,
pixels,
eV,
subslices=[1.0],
tiling=[1, 1],
fe=None,
displacements=True,
fftout=True,
dtype=None,
device=None,
fractional_occupancy=True,
seed=None,
bandwidth_limit=2 / 3,
):
"""
Make the transmission functions for the simulation object.
Transmission functions are the exponential of the specimen electrostatic
potential scaled by the interaction constant for electrons, sigma. These
are used to model scattering by a thin slice of the object in the
multislice algorithm
Parameters:
-----------
pixels : array_like
Output pixel grid
eV : float
Probe accelerating voltage in electron-volts
subslices : array_like, optional
An array containing the depths at which each slice ends as a fraction
of the simulation unit-cell, used for simulation objects thicker
than typical multislice slicing (about 2 Angstrom)
tiling : array_like,optional
Repeat tiling of the simulation object
fe: array_like,optional
An array containing the electron scattering factors for the elements
in the simulation object as calculated by the function
calculate_scattering_factors
"""
# Make the specimen electrostatic potential
T = self.make_potential(
pixels,
subslices,
tiling,
fe=fe,
displacements=displacements,
device=device,
dtype=dtype,
fractional_occupancy=fractional_occupancy,
seed=seed,
)
# Now take the complex exponential of the electrostatic potential
# scaled by the electron interaction constant
T = torch.fft(torch_c_exp(interaction_constant(eV) * T), signal_ndim=2)
# Band-width limit the transmission function, see Earl Kirkland's book
# for an discussion of why this is necessary
for i in range(T.shape[0]):
T[i] = bandwidth_limit_array(T[i], bandwidth_limit)
if fftout:
return torch.ifft(T, signal_ndim=2)
return T
def generate_slicing_figure(self, slices, show=True):
"""
Generate slicing figure.
Generate a slicing figure that to aid in setting up the slicing
of the sample for multislice algorithm. This will show where each of the
slices end for a chosen slicing relative to the atoms. To minimize
errors, the atoms should sit as close to the top of the slice as possible.
Parameters
----------
slices: array_like, float
An array containing the depths at which each slice ends as a fraction
of the simulation unit-cell
"""
fig, ax = plt.subplots(ncols=2, figsize=(8, 4))
coords = self.atoms[:, :3] * self.unitcell[None, :]
# Projection down the x-axis
for i in range(2):
ax[i].plot(coords[:, i], coords[:, 2], "bo", label="Atoms")
for j, slice_ in enumerate(slices):
if j == 0:
label = "Slices"
else:
label = "_"
ax[i].plot(
[0, self.unitcell[i]],
[slice_ * self.unitcell[2], slice_ * self.unitcell[2]],
"r--",
label=label,
)
ax[i].set_xlim([0, self.unitcell[i]])
ax[i].set_xlabel(["y", "x"][i])
ax[i].set_ylim([self.unitcell[2], 0])
ax[i].set_ylabel("z")
ax[i].set_title("View down {0} axis".format(["x", "y"][i]))
ax[0].legend()
if show:
plt.show(block=True)
return fig
def rotate(self, theta, axis, origin=[0.5, 0.5, 0.5]):
"""
Rotate simulation object an amount an angle theta (in radians) about axis.
Parameters
----------
theta: float
Angle to rotate simulation object by in radians
axis: array_like
Axis about which to rotate simulation object eg [0,0,1]
Keyword arguments
------------------
origin : array_like, optional
Origin (in fractional coordinates) about which to rotate simulation
object eg [0.5, 0.5, 0.5]
"""
new = copy.deepcopy(self)
# Make rotation matrix, R, and the point about which we rotate, O
R = rot_matrix(theta, axis)
origin_ = np.asarray(origin) * self.unitcell
# Get atomic coordinates in cartesian (not fractional coordinates)
new.atoms[:, :3] = self.atoms[:, :3] * self.unitcell[np.newaxis, :]
# Apply rotation matrix to each atom coordinate
new.atoms[:, :3] = (new.atoms[:, :3] - origin_) @ R + origin_
# Apply rotation matrix to cell vertices
vertices = (
np.asarray([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 1, 1]])
* self.unitcell
- origin_
) @ R + origin_
# Get new unit cell from maximum range of unit cell vertices
origin_ = np.amin(vertices, axis=0)
new.unitcell = np.ptp(vertices, axis=0)
# Convert atoms back into fractional coordinates in new unit cell
new.atoms[:, :3] = ((new.atoms[:, :3] - origin_) / new.unitcell) % 1.0
# Return rotated structure
return new
def rot90(self, k=1, axes=(0, 1)):
"""
Rotates a structure by 90 degrees in the plane specified by axes.
Rotation direction is from the first towards the second axis.
Parameters
----------
k : integer, optional
Number of times the structure is rotated by 90 degrees.
axes: (2,) array_like
The array is rotated in the plane defined by the axes.
Axes must be different.
"""
# Much of the following is adapted from the numpy.rot90 function
axes = tuple(axes)
if len(axes) != 2:
raise ValueError("len(axes) must be 2.")
k %= 4
if k == 0:
# Do nothing
return
if k == 2:
# Reflect in both axes
self.reflect(axes)
return
axes_list = np.arange(0, 3)
(axes_list[axes[0]], axes_list[axes[1]]) = (
axes_list[axes[1]],
axes_list[axes[0]],
)
if k == 1:
self.reflect([axes[1]])
self.transpose(axes_list)
else:
# k == 3
self.transpose(axes_list)
self.reflect([axes[1]])
return self
def transpose(self, axes):
"""Transpose the axes of a simulation object."""
self.atoms[:, :3] = self.atoms[:, axes]
self.unitcell = self.unitcell[axes]
return self
def tile(self, x=1, y=1, z=1):
"""Make a repeat unit tiling of the simulation object."""
# Make copy of original structure
# new = copy.deepcopy(self)
tiling = np.asarray([x, y, z], dtype=np.int)
# Get atoms in unit cell
natoms = self.atoms.shape[0]
# Initialize new atom list
newatoms = np.zeros((natoms * x * y * z, 6))
# Calculate new unit cell size
self.unitcell = self.unitcell * np.asarray([x, y, z])
# tile out the integer amounts
from itertools import product
for j, k, l in product(*[np.arange(int(i)) for i in [x, y, z]]):
# Calculate origin of this particular tile
origin = np.asarray([j, k, l])
# Calculate index of this particular tile
indx = j * int(y) * int(z) + k * int(z) + l
# Add new atoms to unit cell
newatoms[indx * natoms : (indx + 1) * natoms, :3] = (
self.atoms[:, :3] + origin[np.newaxis, :]
) / tiling[np.newaxis, :]
# Copy other information about atoms
newatoms[indx * natoms : (indx + 1) * natoms, 3:] = self.atoms[:, 3:]
self.atoms = newatoms
return self
def concatenate(self, other, axis=2, side=1, eps=1e-2):
"""
Concatenate two simulation objects.
Adds other simulation object to the current object. other is added to
the bottom (top being z =0) routine will attempt to tile objects to
match dimensions.
Parameters:
other : structure class
Object that will be concatenated onto the other object.
axis : int, optional
Axis along which the two structures will be joined.
side : int, optional
Determines which side the other structure will be added onto the
first structure. If side == 0 the structures will be added onto each
other at the origin, if side == 1 the structures will be added onto
each other at the end.
eps : float, optional
Fractional tolerance of the psuedo rational tiling to make the
structure dimensions perpendicular to the beam direction match.
"""
# Make deep copies of the structure object and the slice this is so
# that these objects remain untouched by the operation of this function
new = copy.deepcopy(self)
other_ = copy.deepcopy(other)
tile1, tile2 = [np.ones(3, dtype=np.int) for i in range(2)]
# Check if the two slices are the same size and
# tile accordingly
for ax in range(3):
# If this axis is the concatenation axis, then it's not necessary
# that the structures are the same size
if ax == axis:
continue
# Calculate the psuedo-rational tiling
if self.unitcell[ax] < other.unitcell[ax]:
tile1[ax], tile2[ax] = psuedo_rational_tiling(
self.unitcell[ax], other.unitcell[ax], eps
)
else:
tile2[ax], tile1[ax] = psuedo_rational_tiling(
other.unitcell[ax], self.unitcell[ax], eps
)
tile1[ax], tile2[ax] = psuedo_rational_tiling(
self.unitcell[ax], other.unitcell[ax], eps
)
new = new.tile(*tile1)
tiled_zdim = new.unitcell[axis]
other_ = other_.tile(*tile2)
# Update the thickness of the resulting structure object.
new.unitcell[axis] = tiled_zdim + other_.unitcell[axis]
# Adjust fractional coordinates of atoms, multiply by old unitcell
# size to transform into cartesian coordinates and then divide by
# the old unitcell size to transform into fractional coordinates
# in the new basis
new.atoms[:, axis] *= tiled_zdim / new.unitcell[axis]
other_.atoms[:, axis] *= other_.unitcell[axis] / new.unitcell[axis]
# Adjust the coordinates of the new or old atoms depending on which
# side the new structure is to be added.
if side == 0:
new.atoms[:, axis] += other_.unitcell[axis] / new.unitcell[axis]
else:
other_.atoms[:, axis] += self.unitcell[axis] / new.unitcell[axis]
# Concatenate adjusted atomic coordinates
new.atoms = np.concatenate([new.atoms, other_.atoms], axis=0)
# Concatenate titles
new.Title = self.Title + " and " + other.Title
return new
def reflect(self, axes):
"""Reflect structure in each of the axes enumerated in list axes."""
for ax in axes:
self.atoms[:, ax] = (1 - self.atoms[:, ax]) % 1.0
return self
def resize(self, fraction, axis):
"""
Resize (either crop or pad with vacuum) the simulation object.
Resize the simulation object ranging such that the new axis runs from
fraction[iax,0] to fraction[iax,1] on specified axis iax, slice_frac is
in units of fractional coordinates. If fraction[iax,0] is < 0 then
additional vacuum will be added, if > 0 then parts of the sample will
be removed for axis[iax]. Likewise if fraction[iax,1] is > 1 then
additional vacuum will be added, if < 1 then parts of the sample will
be removed for axis[iax].
Parameters
----------
fraction : (nax,2) array_like
Describes the size of the new simulation object as a fraction of
old simulation object dimensions.
axis : int or (nax,) array_like
The axes of the simulation object that wil lbe resized
Returns
-------
New structure : pyms.structure object
The resized structure object
"""
ax = ensure_array(axis)
frac = ensure_array(fraction)
if np.asarray(frac).ndim < 2:
frac = [frac]
# Work out which atoms will stay in the sliced structure
mask = np.ones((self.atoms.shape[0],), dtype=np.bool)
for a, f in zip(ax, frac):
atomsin = np.logical_and(self.atoms[:, a] >= f[0], self.atoms[:, a] <= f[1])
mask = np.logical_and(atomsin, mask)
# Make a copy of the structure
new = copy.deepcopy(self)
# Put remaining atoms back in
new.atoms = self.atoms[mask, :]
# Origin for atomic coordinates
origin = np.zeros((3))
for a, f in zip(ax, frac):
# Adjust unit cell dimensions
new.unitcell[a] = (f[1] - f[0]) * self.unitcell[a]
# Adjust origin of atomic coordinates
origin[a] = f[0]
new.atoms[:, :3] = (new.atoms[:, :3] - origin) * self.unitcell / new.unitcell
# Return modified structure
return new
def cshift(self, shift, axis):
"""
Circular shift routine.
Shift the atoms within the simulation cell an amount shift in fractional
coordinates along specified axis (or axes if both shift and axis are
array_like).
Parameters
----------
shift : array_like or int
Amount in fractional coordinates to shift (each) axis.
axis : array_like or int
Axis or list of axes to apply shift(s) to.
"""
def _cshift(atoms, x, ax):
atoms[:, ax % 3] = np.mod(atoms[:, ax % 3] + x, 1.0)
return atoms
if hasattr(axis, "__len__"):
for x, ax in zip(shift, axis):
self.atoms = _cshift(self.atoms, x, ax)
else:
self.atoms = _cshift(self.atoms, shift, axis)
return self
class layered_structure_transmission_function:
"""
A class that mimics multislice transmission functions for a layered object.
Useful for performing multislice calculations of heterostructures (epitaxially
layered cystalline structures).
"""
def __init__(
self,
gridshape,
eV,
structures,
nslices,
subslices,
tilings=None,
kwargs={},
nT=5,
dtype=torch.float32,
device=None,
specimen_tilt=[0, 0],
):
"""
Generate a layered structure transmission function object.
This function assumes that the lateral (x and y) cell sizes of the
structures are identical,
Input
-----
structures : (N,) array_like of pyms.Structure objects
The input structures for which the transmission functions for a
layered structure will be calculated.
nslices : int (N,) array_like
The number of units of each structure in the multilayer
subslices : array_like (N,) of array_like
Multislice subslicing for each object in the multilayer structure
Returns
-----
self : layered_structure_transmission_function object
This will behave like a normal transmission function array, if
T = layered_structure_transmission_function(...,[structure1,structure2 etc])
then T[0,islice,...] will return a transmission function from whichever
structure islice happens to be in. T.Propagator[islice] returns the
relevant multislice propagator
"""
self.dtype = dtype
self.device = get_device(device)
self.nslicestot = np.sum(nslices)
self.structures = structures
if tilings is None:
tilings = len(structures) * [[1, 1]]
self.Ts = []
self.nT = nT
self.gridshape = gridshape
self.tilings = tilings
self.eV = eV
self.specimen_tilt = specimen_tilt
self.unitcell = np.zeros(3)
self.unitcell[:2] = structures[0].unitcell[:2] # * np.asarray(tilings[0])
self.unitcell[2] = np.sum(
[struc.unitcell[2] * nslice for struc, nslice in zip(structures, nslices)]
)
args = [gridshape, eV]
# Like every Melbourne restaurant, within the slab structure we do things
# a little differently: since the number of subslices can be different
# for each structure we have to store the transmission functions for
# each structure in a list so the indexing of self.Ts is
# self.Ts[istructure][iT][isubslice], the __get_item__ method
# makes indexing this synthetic object consistent with standard practice
for structure, subslices_, tiling in zip(structures, subslices, tilings):
self.Ts.append(
torch.stack(
[
structure.make_transmission_functions(
*args,
subslices=subslices_,
tiling=tiling,
**kwargs,
device=self.device,
dtype=self.dtype,
)
for i in range(nT)
]
)
)
self.slicemap = list(
itertools.chain(
*[len(subslices[i]) * n * [i] for i, n in enumerate(nslices)]
)
)
nsubslices = [len(subslice) for subslice in subslices]
self.subslicemap = list(
itertools.chain(
*[
(np.arange(nsubslices[i] * n) % nsubslices[i]).tolist()
for i, n in enumerate(nslices)
]
)
)
self.N = len(self.slicemap)
self.subslices = []
T = 0
# Calculate the fractional depth of every subslice in the new synthetic
# structure.
for subslices_, slices, struct in zip(subslices, nslices, structures):
for i in range(slices):
self.subslices += (
(np.asarray(subslices_) * struct.unitcell[2] + T) / self.unitcell[2]
).tolist()
T = T + struct.unitcell[2]
# Mimics the shape property of a numpy array
self.shape = (self.nT, self.N, *self.gridshape, 2)
self.Propagator = layered_structure_propagators(
self, subslices, propkwargs={"tilt": specimen_tilt}
)
def dim(self):
"""Return the array dimension of the synthetic array."""
return 4
def __getitem__(self, ind):
"""
__getitem__ method for the transmission function synthetic array.
This enables the transmission function object to mimic a standard
transmission function numpy or torch.Tensor array
"""
it, islice = ind[:2]
# First get the proper slice and subslice, self.Ts is a list object
# with each entry containing the transmission functions for that
# structure
if isinstance(islice, int) or np.issubdtype(
np.asarray(islice).dtype, np.integer
):
T = self.Ts[self.slicemap[islice]][:, self.subslicemap[islice]]
elif isinstance(islice, slice):
islice_ = np.arange(*islice.indices(self.N))
T = torch.stack(
[self.Ts[self.slicemap[j]][:, self.subslicemap[j]] for j in islice_],
axis=1,
)
else:
raise TypeError("Invalid argument type.")
if isinstance(it, int) or np.issubdtype(np.asarray(it).dtype, np.integer):
return T[it]
elif isinstance(it, slice):
it_ = np.arange(*it.indices(self.nT))
return T[it_]
else:
raise TypeError("Invalid argument type.")
class layered_structure_propagators:
"""
A class that mimics multislice propagators for a layered object.
Complements layered_transmission_function
"""
def __init__(self, layered_T, subslices, propkwargs={}):
"""
Generate a layered structure multislice propagator function object.
This function assumes that the lateral (x and y) cell sizes of the
structures are identical,
Input
-----
T : layered_structure_transmission_function object
This should contain all the neccessary information about the layered
object to generate the propagators
Keyword arguements:
-------------------
propkwargs : dict
Keyword arguements to pass onto make_propagator function
Returns
-----
self : layered_structure_propagators object
This will behave like a normal propagator array, if
P = layered_structure_propagators(T)
then P[islice,...] will return a transmission function from whichever
structure islice happens to be in.
"""
from .py_multislice import make_propagators
self.rsizes = [
copy.deepcopy(struc.unitcell * np.asarray(t + [1]))
for struc, t in zip(layered_T.structures, layered_T.tilings)
]
self.Ps = [
cx_from_numpy(
make_propagators(layered_T.gridshape, r, layered_T.eV, s, **propkwargs),
layered_T.dtype,
layered_T.device,
)
for s, r in zip(subslices, self.rsizes)
]
self.slicemap = layered_T.slicemap
self.subslicemap = layered_T.subslicemap
# Mimics the shape property of a numpy array
self.shape = (layered_T.nslicestot, *layered_T.gridshape, 2)
self.ndim = 4
def dim(self):
"""Return the array dimension of the synthetic array."""
return 4
def __getitem__(self, islice):
"""
__getitem__ method for the propagator synthetic array.
This enables the propagator object to mimic a standard propagator numpy
or torch.Tensor array
"""
if isinstance(islice, int) or np.issubdtype(
np.asarray(islice).dtype, np.integer
):
return self.Ps[self.slicemap[islice]][self.subslicemap[islice]]
elif isinstance(islice, slice):
islice_ = np.arange(*islice.indices(len(self.slicemap)))
return torch.stack(
[self.Ps[self.slicemap[j]][self.subslicemap[j]] for j in islice_]
)
else:
raise TypeError("Invalid argument type.")
Functions
def Xray_scattering_factor(Z, gsq, units='A')
-
Calculate the X-ray scattering factor for atom with atomic number Z.
Parameters
Z
:int
- Atomic number of atom of interest.
gsq
:float
orarray_like
- Reciprocal space value(s) in Angstrom squared at which to evaluate the X-ray scattering factor.
units
:string
, optional- Units in which to calculate X-ray scattering factor, can be 'A' for Angstrom, or 'VA' for volt-Angstrom.
Expand source code
def Xray_scattering_factor(Z, gsq, units="A"): """ Calculate the X-ray scattering factor for atom with atomic number Z. Parameters ---------- Z : int Atomic number of atom of interest. gsq : float or array_like Reciprocal space value(s) in Angstrom squared at which to evaluate the X-ray scattering factor. units : string, optional Units in which to calculate X-ray scattering factor, can be 'A' for Angstrom, or 'VA' for volt-Angstrom. """ # Bohr radius in Angstrom a0 = 0.529177 # gsq = g**2 return Z - 2 * np.pi ** 2 * a0 * gsq * electron_scattering_factor( Z, gsq, units=units )
def calculate_scattering_factors(gridshape, gridsize, elements)
-
Calculate the electron scattering factors on a reciprocal space grid.
Parameters
gridshape
:(2,) array_like
- pixel size of the grid
gridsize
:(2,) array_like
- Lateral real space sizing of the grid in Angstrom
elements
:(M,) array_like
- List of elements for which electron scattering factors are required
Returns
fe
:(M, *gridshape)
- Array of electron scattering factors in reciprocal space for each element
Expand source code
def calculate_scattering_factors(gridshape, gridsize, elements): """Calculate the electron scattering factors on a reciprocal space grid. Parameters ---------- gridshape : (2,) array_like pixel size of the grid gridsize : (2,) array_like Lateral real space sizing of the grid in Angstrom elements : (M,) array_like List of elements for which electron scattering factors are required Returns ------- fe : (M, *gridshape) Array of electron scattering factors in reciprocal space for each element """ # Get reciprocal space array g = q_space_array(gridshape, gridsize) gsq = np.square(g[0]) + np.square(g[1]) # Initialise scattering factor array fe = np.zeros((len(elements), *gridshape), dtype=np.float32) # Loop over unique elements for ielement, element in enumerate(elements): fe[ielement, :, :] = electron_scattering_factor(element, gsq) return fe
def change_of_basis(coords, newuc, olduc)
-
Change of basis for structure unit cell.
Expand source code
def change_of_basis(coords, newuc, olduc): """Change of basis for structure unit cell.""" return np.mod(coords[:, :3] @ olduc @ np.linalg.inv(newuc), 1.0)
def electron_scattering_factor(Z, gsq, units='VA')
-
Calculate the electron scattering factor for atom with atomic number Z.
Parameters
Z
:int
- Atomic number of atom of interest.
gsq
:float
orarray_like
- Reciprocal space value(s) in Angstrom squared at which to evaluate the electron scattering factor.
units
:string
, optional- Units in which to calculate electron scattering factor, can be 'A' for Angstrom, or 'VA' for volt-Angstrom.
Expand source code
def electron_scattering_factor(Z, gsq, units="VA"): """ Calculate the electron scattering factor for atom with atomic number Z. Parameters ---------- Z : int Atomic number of atom of interest. gsq : float or array_like Reciprocal space value(s) in Angstrom squared at which to evaluate the electron scattering factor. units : string, optional Units in which to calculate electron scattering factor, can be 'A' for Angstrom, or 'VA' for volt-Angstrom. """ ai = e_scattering_factors[Z - 1, 0:10:2] bi = e_scattering_factors[Z - 1, 1:10:2] # Planck's constant in kg Angstrom/s h = 6.62607004e-24 # Electron rest mass in kg me = 9.10938356e-31 # Electron charge in Coulomb qe = 1.60217662e-19 fe = np.zeros_like(gsq) for i in range(5): fe += ai[i] * (2 + bi[i] * gsq) / (1 + bi[i] * gsq) ** 2 # Result can be returned in units of Volt Angstrom ('VA') or Angstrom ('A') if units == "VA": return h ** 2 / (2 * np.pi * me * qe) * fe elif units == "A": return fe
def find_equivalent_sites(positions, EPS=0.001)
-
Find equivalent atomic sites in a list of atomic positions object.
This function is used to detect two atoms sharing the same postions (are with EPS of each other) with fractional occupancy, and return an index of these equivalent sites.
Expand source code
def find_equivalent_sites(positions, EPS=1e-3): """Find equivalent atomic sites in a list of atomic positions object. This function is used to detect two atoms sharing the same postions (are with EPS of each other) with fractional occupancy, and return an index of these equivalent sites. """ # Import the pair-wise distance function from scipy from scipy.spatial.distance import pdist natoms = positions.shape[0] # Calculate pairwise distance between each atomic site distance_matrix = pdist(positions) # Initialize index of equivalent sites (initially assume all sites are # independent) equivalent_sites = np.arange(natoms, dtype=np.int) # Find equivalent sites equiv = distance_matrix < EPS # If there are equivalent sites correct the index of equivalent sites if np.any(equiv): # Masking function to get indices from distance_matrix iu = np.mask_indices(natoms, np.triu, 1) # Get a list of equivalent sites sites = np.nonzero(equiv)[0] for site in sites: # Use the masking function to equivalent_sites[iu[1][site]] = iu[0][site] return equivalent_sites
def interaction_constant(E, units='rad/VA')
-
Calculate the electron interaction constant, sigma.
The electron interaction constant converts electrostatic potential (in V Angstrom) to radians. Units of this constant are rad/(V Angstrom). See Eq. (2.5) in Kirkland's Advanced Computing in electron microscopy.
Expand source code
def interaction_constant(E, units="rad/VA"): """ Calculate the electron interaction constant, sigma. The electron interaction constant converts electrostatic potential (in V Angstrom) to radians. Units of this constant are rad/(V Angstrom). See Eq. (2.5) in Kirkland's Advanced Computing in electron microscopy. """ # Planck's constant in kg Angstrom /s h = 6.62607004e-24 # Electron rest mass in kg me = 9.10938356e-31 # Electron charge in Coulomb qe = 1.60217662e-19 # Electron wave number (reciprocal of wavelength) in Angstrom k0 = wavev(E) # Relativistic electron mass correction gamma = relativistic_mass_correction(E) if units == "rad/VA": return 2 * np.pi * gamma * me * qe / k0 / h / h elif units == "rad/A": return gamma / k0
def psuedo_rational_tiling(dim1, dim2, EPS)
-
Calculate the psuedo-rational tiling for matching objects of different dimensions.
For two dimensions, dim1 and dim2, work out the multiplicative tiling so that those dimensions might be matched to within error EPS.
Expand source code
def psuedo_rational_tiling(dim1, dim2, EPS): """ Calculate the psuedo-rational tiling for matching objects of different dimensions. For two dimensions, dim1 and dim2, work out the multiplicative tiling so that those dimensions might be matched to within error EPS. """ if np.any([dim1 == 0, dim2 == 0]): return 1, 1 tile1 = int(np.round(np.abs(dim2 / dim1) / EPS)) tile2 = int(np.round(1 / EPS)) return remove_common_factors([tile1, tile2])
def remove_common_factors(nums)
-
Remove common divisible factors from a set of numbers.
Expand source code
def remove_common_factors(nums): """Remove common divisible factors from a set of numbers.""" nums = np.asarray(nums, dtype=np.int) g_ = np.gcd.reduce(nums) while g_ > 1: nums //= g_ g_ = np.gcd.reduce(nums) return nums
def rot_matrix(theta, u=array([0., 0., 1.]))
-
Generate a 3D rotational matrix.
Parameters
theta
:float
- Angle of rotation in radians
u
:(3,) array_like
- Axis of rotation
Expand source code
def rot_matrix(theta, u=np.asarray([0, 0, 1], dtype=np.float)): """ Generate a 3D rotational matrix. Parameters ---------- theta : float Angle of rotation in radians u : (3,) array_like Axis of rotation """ from numpy import sin, cos c = cos(theta) s = sin(theta) ux, uy, uz = u / np.linalg.norm(u) R = np.zeros((3, 3)) R[0, :] = [ c + ux * ux * (1 - c), ux * uy * (1 - c) - uz * s, ux * uz * (1 - c) + uy * s, ] R[1, :] = [ uy * uz * (1 - c) + uz * s, c + uy * uy * (1 - c), uy * uz * (1 - c) - ux * s, ] R[2, :] = [ uz * ux * (1 - c) - uy * s, uz * uy * (1 - c) + ux * s, c + uz * uz * (1 - c), ] return R
Classes
class layered_structure_propagators (layered_T, subslices, propkwargs={})
-
A class that mimics multislice propagators for a layered object.
Complements layered_transmission_function
Generate a layered structure multislice propagator function object.
This function assumes that the lateral (x and y) cell sizes of the structures are identical,
Input
T : layered_structure_transmission_function object This should contain all the neccessary information about the layered object to generate the propagators
Keyword arguements:
propkwargs : dict Keyword arguements to pass onto make_propagator function
Returns
self
:layered_structure_propagators object
- This will behave like a normal propagator array, if P = layered_structure_propagators(T) then P[islice,…] will return a transmission function from whichever structure islice happens to be in.
Expand source code
class layered_structure_propagators: """ A class that mimics multislice propagators for a layered object. Complements layered_transmission_function """ def __init__(self, layered_T, subslices, propkwargs={}): """ Generate a layered structure multislice propagator function object. This function assumes that the lateral (x and y) cell sizes of the structures are identical, Input ----- T : layered_structure_transmission_function object This should contain all the neccessary information about the layered object to generate the propagators Keyword arguements: ------------------- propkwargs : dict Keyword arguements to pass onto make_propagator function Returns ----- self : layered_structure_propagators object This will behave like a normal propagator array, if P = layered_structure_propagators(T) then P[islice,...] will return a transmission function from whichever structure islice happens to be in. """ from .py_multislice import make_propagators self.rsizes = [ copy.deepcopy(struc.unitcell * np.asarray(t + [1])) for struc, t in zip(layered_T.structures, layered_T.tilings) ] self.Ps = [ cx_from_numpy( make_propagators(layered_T.gridshape, r, layered_T.eV, s, **propkwargs), layered_T.dtype, layered_T.device, ) for s, r in zip(subslices, self.rsizes) ] self.slicemap = layered_T.slicemap self.subslicemap = layered_T.subslicemap # Mimics the shape property of a numpy array self.shape = (layered_T.nslicestot, *layered_T.gridshape, 2) self.ndim = 4 def dim(self): """Return the array dimension of the synthetic array.""" return 4 def __getitem__(self, islice): """ __getitem__ method for the propagator synthetic array. This enables the propagator object to mimic a standard propagator numpy or torch.Tensor array """ if isinstance(islice, int) or np.issubdtype( np.asarray(islice).dtype, np.integer ): return self.Ps[self.slicemap[islice]][self.subslicemap[islice]] elif isinstance(islice, slice): islice_ = np.arange(*islice.indices(len(self.slicemap))) return torch.stack( [self.Ps[self.slicemap[j]][self.subslicemap[j]] for j in islice_] ) else: raise TypeError("Invalid argument type.")
Methods
def dim(self)
-
Return the array dimension of the synthetic array.
Expand source code
def dim(self): """Return the array dimension of the synthetic array.""" return 4
class layered_structure_transmission_function (gridshape, eV, structures, nslices, subslices, tilings=None, kwargs={}, nT=5, dtype=torch.float32, device=None, specimen_tilt=[0, 0])
-
A class that mimics multislice transmission functions for a layered object.
Useful for performing multislice calculations of heterostructures (epitaxially layered cystalline structures).
Generate a layered structure transmission function object.
This function assumes that the lateral (x and y) cell sizes of the structures are identical,
Input
structures : (N,) array_like of pyms.Structure objects The input structures for which the transmission functions for a layered structure will be calculated. nslices : int (N,) array_like The number of units of each structure in the multilayer subslices : array_like (N,) of array_like Multislice subslicing for each object in the multilayer structure
Returns
self
:layered_structure_transmission_function object
- This will behave like a normal transmission function array, if T = layered_structure_transmission_function(…,[structure1,structure2 etc]) then T[0,islice,…] will return a transmission function from whichever structure islice happens to be in. T.Propagator[islice] returns the relevant multislice propagator
Expand source code
class layered_structure_transmission_function: """ A class that mimics multislice transmission functions for a layered object. Useful for performing multislice calculations of heterostructures (epitaxially layered cystalline structures). """ def __init__( self, gridshape, eV, structures, nslices, subslices, tilings=None, kwargs={}, nT=5, dtype=torch.float32, device=None, specimen_tilt=[0, 0], ): """ Generate a layered structure transmission function object. This function assumes that the lateral (x and y) cell sizes of the structures are identical, Input ----- structures : (N,) array_like of pyms.Structure objects The input structures for which the transmission functions for a layered structure will be calculated. nslices : int (N,) array_like The number of units of each structure in the multilayer subslices : array_like (N,) of array_like Multislice subslicing for each object in the multilayer structure Returns ----- self : layered_structure_transmission_function object This will behave like a normal transmission function array, if T = layered_structure_transmission_function(...,[structure1,structure2 etc]) then T[0,islice,...] will return a transmission function from whichever structure islice happens to be in. T.Propagator[islice] returns the relevant multislice propagator """ self.dtype = dtype self.device = get_device(device) self.nslicestot = np.sum(nslices) self.structures = structures if tilings is None: tilings = len(structures) * [[1, 1]] self.Ts = [] self.nT = nT self.gridshape = gridshape self.tilings = tilings self.eV = eV self.specimen_tilt = specimen_tilt self.unitcell = np.zeros(3) self.unitcell[:2] = structures[0].unitcell[:2] # * np.asarray(tilings[0]) self.unitcell[2] = np.sum( [struc.unitcell[2] * nslice for struc, nslice in zip(structures, nslices)] ) args = [gridshape, eV] # Like every Melbourne restaurant, within the slab structure we do things # a little differently: since the number of subslices can be different # for each structure we have to store the transmission functions for # each structure in a list so the indexing of self.Ts is # self.Ts[istructure][iT][isubslice], the __get_item__ method # makes indexing this synthetic object consistent with standard practice for structure, subslices_, tiling in zip(structures, subslices, tilings): self.Ts.append( torch.stack( [ structure.make_transmission_functions( *args, subslices=subslices_, tiling=tiling, **kwargs, device=self.device, dtype=self.dtype, ) for i in range(nT) ] ) ) self.slicemap = list( itertools.chain( *[len(subslices[i]) * n * [i] for i, n in enumerate(nslices)] ) ) nsubslices = [len(subslice) for subslice in subslices] self.subslicemap = list( itertools.chain( *[ (np.arange(nsubslices[i] * n) % nsubslices[i]).tolist() for i, n in enumerate(nslices) ] ) ) self.N = len(self.slicemap) self.subslices = [] T = 0 # Calculate the fractional depth of every subslice in the new synthetic # structure. for subslices_, slices, struct in zip(subslices, nslices, structures): for i in range(slices): self.subslices += ( (np.asarray(subslices_) * struct.unitcell[2] + T) / self.unitcell[2] ).tolist() T = T + struct.unitcell[2] # Mimics the shape property of a numpy array self.shape = (self.nT, self.N, *self.gridshape, 2) self.Propagator = layered_structure_propagators( self, subslices, propkwargs={"tilt": specimen_tilt} ) def dim(self): """Return the array dimension of the synthetic array.""" return 4 def __getitem__(self, ind): """ __getitem__ method for the transmission function synthetic array. This enables the transmission function object to mimic a standard transmission function numpy or torch.Tensor array """ it, islice = ind[:2] # First get the proper slice and subslice, self.Ts is a list object # with each entry containing the transmission functions for that # structure if isinstance(islice, int) or np.issubdtype( np.asarray(islice).dtype, np.integer ): T = self.Ts[self.slicemap[islice]][:, self.subslicemap[islice]] elif isinstance(islice, slice): islice_ = np.arange(*islice.indices(self.N)) T = torch.stack( [self.Ts[self.slicemap[j]][:, self.subslicemap[j]] for j in islice_], axis=1, ) else: raise TypeError("Invalid argument type.") if isinstance(it, int) or np.issubdtype(np.asarray(it).dtype, np.integer): return T[it] elif isinstance(it, slice): it_ = np.arange(*it.indices(self.nT)) return T[it_] else: raise TypeError("Invalid argument type.")
Methods
def dim(self)
-
Return the array dimension of the synthetic array.
Expand source code
def dim(self): """Return the array dimension of the synthetic array.""" return 4
class structure (unitcell, atoms, dwf, occ=None, Title='', EPS=0.01)
-
Class for simulation objects.
Elements in a structure object: unitcell : An array containing the side lengths of the orthorhombic unit cell atoms : An array of dimensions total number of atoms by 6 which for each atom contains the fractional cooordinates within the unit cell for each atom in the first three entries, the atomic number in the fourth entry, the atomic occupancy (not yet implemented in the multislice) in the fifth entry and mean squared atomic displacement in the sixth entry Title : Short description of the object of output purposes
Initialize a simulation object with necessary variables.
Expand source code
class structure: """ Class for simulation objects. Elements in a structure object: unitcell : An array containing the side lengths of the orthorhombic unit cell atoms : An array of dimensions total number of atoms by 6 which for each atom contains the fractional cooordinates within the unit cell for each atom in the first three entries, the atomic number in the fourth entry, the atomic occupancy (not yet implemented in the multislice) in the fifth entry and mean squared atomic displacement in the sixth entry Title : Short description of the object of output purposes """ def __init__(self, unitcell, atoms, dwf, occ=None, Title="", EPS=1e-2): """Initialize a simulation object with necessary variables.""" self.unitcell = np.asarray(unitcell) natoms = np.asarray(atoms).shape[0] if occ is None: occ = np.ones(natoms) self.atoms = np.concatenate( [atoms, occ.reshape(natoms, 1), np.asarray(dwf).reshape(natoms, 1)], axis=1 ) self.Title = Title # Up till now unitcell can be a 3 x 3 matrix with rows describing the # unit cell edges. If this is the case we need to make sure that the # unit cell is orthorhombic and find an orthorhombic tiling if this it # is not orthorhombic if self.unitcell.ndim > 1: # Check to see if unit cell is orthorhombic ortho = np.abs(np.sum(self.unitcell) - np.trace(self.unitcell)) < EPS if ortho: # If unit cell is orthorhombic then extract unit cell # dimension self.unitcell = np.diag(self.unitcell) else: # If not orthorhombic attempt psuedo rational tiling self.orthorhombic_supercell(EPS=EPS) # Check if there is any fractional occupancy of atom sites in # the sample self.fractional_occupancy = np.any(np.abs(self.atoms[:, 4] - 1.0) > 1e-3) @classmethod def fromfile( cls, fnam, temperature_factor_units="ums", atomic_coordinates="fractional", EPS=1e-2, T=None, ): """ Read in a simulation object from a structure file. Appropriate structure files include *.p1 files, which is outputted by the vesta software: K. Momma and F. Izumi, "VESTA 3 for three-dimensional visualization of crystal, volumetric and morphology data," J. Appl. Crystallogr., 44, 1272-1276 (2011). or a *.xyz file in the standard of the prismatic software Parameters ---------- fnam : string Filepath of the structure file temperature_factor_units : string,optional Units of the Debye-Waller temperature factors in the structure file appropriate inputs are B (crystallographic temperature factor), urms (root mean squared displacement) and ums (mean squared displacement, the default) atomic_coordinates : string, optional Units of the atomic coordinates can be "fractional" or "cartesian" EPS : float,optional Tolerance for procedures such as psuedo-rational tiling for non-orthorhombic crystal unit cells T : (3,3) array_like or None An optional transformation matrix to be applied to the unit cell and the atomic coordinates """ f = open(fnam, "r") ext = splitext(fnam)[1].lower() # Read title Title = f.readline().strip() if ext == ".p1": # I have no idea what the second line in the p1 file format means # so ignore it f.readline() # Get unit cell vector - WARNING assume an orthorhombic unit cell unitcell = np.loadtxt(f, max_rows=3, dtype=np.float) # Get the atomic symbol of each element atomtypes = np.loadtxt(f, max_rows=1, dtype=str, ndmin=1) # noqa # Get the number of atoms of each type natoms = np.loadtxt(f, max_rows=1, dtype=int, ndmin=1) # Skip empty line f.readline() # Total number of atoms totnatoms = np.sum(natoms) # Intialize array containing atomic information atoms = np.zeros((totnatoms, 6)) dwf = np.zeros((totnatoms,)) occ = np.zeros((totnatoms,)) for i in range(totnatoms): atominfo = split(r"\s+", f.readline().strip())[:6] # First three entries are the atomic coordinates atoms[i, :3] = np.asarray(atominfo[:3], dtype=np.float) # Fourth entry is the atomic symbol atoms[i, 3] = atomic_symbol.index( match("([A-Za-z]+)", atominfo[3]).group(0) ) # Final entries are the fractional occupancy and the temperature # (Debye-Waller) factor occ[i] = atominfo[4] dwf[i] = atominfo[5] elif ext == ".xyz": # Read in unit cell dimensions unitcell = np.asarray( [float(x) for x in split(r"\s+", f.readline().strip())[:3]] ) atoms = [] for line in f: # Look for end of file marker if line.strip() == "-1": break # Otherwise parse line atoms.append( np.array([float(x) for x in split(r"\s+", line.strip())[:6]]) ) # Now stack all atoms into numpy array atoms_ = np.stack(atoms, axis=0) # Rearrange columns of numpy array to match standard totnatoms = atoms_.shape[0] atoms = np.zeros((totnatoms, 4)) # Atomic coordinates atoms[:, :3] = atoms_[:, 1:4] # Atomic numbers (Z) atoms[:, 3] = atoms_[:, 0] # Fractional occupancy and Debye-Waller (temperature) factor dwf = atoms_[:, 5] occ = atoms_[:, 4] else: print("File extension: {0} not recognized".format(ext)) return None # Close file f.close() # If temperature factors are given in any other format than mean square # (ums) convert to mean square. Acceptable formats are crystallographic # temperature factor B and root mean square (urms) displacements if temperature_factor_units == "B": dwf *= 1 / (8 * np.pi ** 2) elif temperature_factor_units == "urms": dwf = dwf ** 2 elif temperature_factor_units == "ums": pass else: raise ValueError("Unrecognized temperature factor units") # If necessary, Convert atomic positions to fractional coordinates if atomic_coordinates == "cartesian": atoms[:, :3] /= unitcell[:3][np.newaxis, :] atoms[:, :3] = atoms[:, :3] % 1.0 if T is not None: # Transform atoms to cartesian basis and then apply transformation # matrix atoms[:, :3] = (T @ unitcell @ atoms[:, :3].T).T # Apply transformation matrix to unit-cell unitcell = unitcell @ T.T # Apply inverse of unit cell atoms[:, :3] = (np.linalg.inv(unitcell) @ atoms[:, :3].T).T return cls(unitcell, atoms[:, :4], dwf, occ, Title, EPS=EPS) @classmethod def from_ase_cluster(cls, asecell, occupancy=None, Title="", dwf=None): """Initialize from Atomic Simulation Environment (ASE) cluster object.""" unitcell = asecell.cell[:] natoms = asecell.numbers.shape[0] atoms = np.concatenate( [ asecell.cell.scaled_positions(asecell.positions), asecell.numbers.reshape(natoms, 1), ], axis=1, ) if occupancy is None: occ = np.ones(natoms) if dwf is None: dwf = np.ones(natoms) * 3 / np.pi ** 2 / 8 return cls(unitcell, atoms, dwf, occ, Title) def to_ase_atoms(self): """Convert structure to Atomic Simulation Environment (ASE) atoms object.""" scaled_positions = self.atoms[:, :3] numbers = self.atoms[:, 3].astype(np.int) cell = self.unitcell pbc = [True, True, True] return ase.Atoms( scaled_positions=scaled_positions, numbers=numbers, cell=cell, pbc=pbc ) def orthorhombic_supercell(self, EPS=1e-2): """ Create an orthorhombic supercell from a monoclinic crystal unit cell. If not orthorhombic attempt psuedo rational tiling of general monoclinic structure. Assumes that the self.unitcell matrix is lower triangular. """ if not np.abs(np.dot(self.unitcell[0], self.unitcell[1])) < EPS: tiley, tilex = psuedo_rational_tiling(*self.unitcell[0:2, 0], EPS) # Make deepcopy of old unit cell olduc = copy.deepcopy(self.unitcell) # Tile out atoms self.tile(tiley, tilex, 1) # Calculate size of old unit cell under tiling olduc = np.asarray([tiley, tilex, 1])[:, np.newaxis] * olduc self.unitcell = copy.deepcopy(olduc) self.unitcell[1, 0] = 0.0 # Now calculate fractional coordinates in new orthorhombic cell self.atoms[:, :3] = change_of_basis(self.atoms[:, :3], self.unitcell, olduc) else: self.unitcell[0, 1:] = 0.0 self.unitcell[1, ::2] = 0.0 # Now tile crystal in x and y tilez1, tiley = psuedo_rational_tiling(*self.unitcell[::-2, 0], EPS) tilez2, tilex = psuedo_rational_tiling(*self.unitcell[3:0:-1, 1], EPS) tilez = remove_common_factors([tilez1, tilez2, tilez1 * tilez2])[-1] tiley *= tilez // tilez1 tilex *= tilez // tilez2 olduc = copy.deepcopy(self.unitcell) # Tile out atoms self.tile(tiley, tilex, tilez) # Calculate size of old unit cell under tiling olduc = np.asarray([tiley, tilex, tilez])[:, np.newaxis] * olduc self.unitcell = copy.deepcopy(olduc) self.unitcell[2, 0:2] = 0.0 # Now calculate fractional coordinates in new orthorhombic cell self.atoms[:, :3] = np.mod( self.atoms[:, :3] @ olduc @ np.linalg.inv(self.unitcell), 1.0 ) self.unitcell = np.diag(self.unitcell) # Check for negative values of self.unitcell and rectify for i in range(3): if self.unitcell[i] < 0: self.atoms[:, i] = (1.0 - self.atoms[:, i]) % 1.0 self.unitcell = np.abs(self.unitcell) def quickplot( self, atomscale=None, cmap=plt.get_cmap("Dark2"), block=True, colors=None ): """ Make a quick 3D scatter plot of the atomic sites within the structure. For more detailed visualization output the structure file to a file format readable by the Vesta software using output_vesta_xtl """ from mpl_toolkits.mplot3d import Axes3D # NOQA if atomscale is None: atomscale = 1e-3 * np.amax(self.unitcell) fig = plt.figure() ax = fig.add_subplot(111, projection="3d") if colors is None: colors = cmap(self.atoms[:, 3] / np.amax(self.atoms[:, 3])) sizes = self.atoms[:, 3] * atomscale ax.scatter( *[self.atoms[:, i] * self.unitcell[i] for i in [1, 0, 2]], c=colors, s=sizes ) ax.set_xlim3d(0.0, self.unitcell[1]) ax.set_ylim3d(top=0.0, bottom=self.unitcell[0]) ax.set_zlim3d(top=0.0, bottom=self.unitcell[2]) ax.set_xlabel("x") ax.set_ylabel("y") ax.set_zlabel("z") plt.show(block=block) return fig def output_vesta_xtl(self, fnam): """Output an .xtl file which is viewable by the vesta software. See K. Momma and F. Izumi, "VESTA 3 for three-dimensional visualization of crystal, volumetric and morphology data," J. Appl. Crystallogr., 44, 1272-1276 (2011). Warning: Vesta xtl files do not contain fractional occupancy information """ f = open(splitext(fnam)[0] + ".xtl", "w") f.write("TITLE " + self.Title + "\n CELL \n") f.write(" {0:.5f} {1:.5f} {2:.5f} 90 90 90\n".format(*self.unitcell)) f.write("SYMMETRY NUMBER 1\n SYMMETRY LABEL P1\n ATOMS \n") f.write("NAME X Y Z" + "\n") for i in range(self.atoms.shape[0]): f.write( "{0} {1:.4f} {2:.4f} {3:.4f}\n".format( atomic_symbol[int(self.atoms[i, 3])], *self.atoms[i, :3] ) ) f.write("EOF") f.close() def output_xyz( self, fnam, atomic_coordinates="cartesian", temperature_factor_units="sqrturms" ): """ Output an .xyz structure file. This is the input format used by Kirkland's EM codes and the prismatic software. """ f = open(splitext(fnam)[0] + ".xyz", "w") f.write(self.Title + "\n {0:.4f} {1:.4f} {2:.4f}\n".format(*self.unitcell)) if atomic_coordinates == "cartesian": coords = self.atoms[:, :3] * self.unitcell else: coords = self.atoms[:, :3] # If temperature factors are given as B then convert to urms if temperature_factor_units == "B": DWFs = self.atoms[:, 5] * 8 * np.pi ** 2 elif temperature_factor_units == "sqrturms": DWFs = np.sqrt(self.atoms[:, 5]) for coord, atom, DWF in zip(coords, self.atoms, DWFs): f.write( "{0:d} {1:.4f} {2:.4f} {3:.4f} {4:.2f} {5:.3f}\n".format( int(atom[3]), *coord, atom[4], DWF ) ) f.write("-1") f.close() def make_potential( self, pixels, subslices=[1.0], tiling=[1, 1], displacements=True, fractional_occupancy=True, fe=None, device=None, dtype=torch.float32, seed=None, ): """ Generate the projected potential of the structure. Calculate the projected electrostatic potential for a structure on a pixel grid with dimensions specified by pixels. Subslicing the unit cell is achieved by passing an array subslices that contains as its entries the depths at which each subslice should be terminated in units of fractional coordinates. Tiling of the unit cell (often necessary to make a sufficiently large simulation grid to fit the probe) is achieved by passing the tiling factors in the array tiling. Parameters ---------- pixels: int, (2,) array_like The pixel size of the grid on which to calculate the projected potentials subslices: float, array_like, optional An array containing the depths at which each slice ends as a fraction of the simulation unit-cell tiling: int, (2,) array_like, optional Tiling of the simulation object (often necessary to make a sufficiently large simulation grid to fit the probe) displacements: bool, optional Pass displacements = False to turn off random displacements of the atoms due to thermal motion fractional_occupancy: bool, optional Pass fractional_occupancy = False to turn off fractional occupancy of atomic sites fe: float, array_like An array containing the electron scattering factors for the elements in the structure as calculated by the function calculate_scattering_factors, can be passed to save recalculating each time new potentials are generated device: torch.device Allows the user to control which device the calculations will occur on dtype: torch.dtype Controls the data-type of the output seed: int Seed for random number generator for atomic displacements. """ # Initialize device cuda if available, CPU if no cuda is available device = get_device(device) # Ensure pixels is integer pixels_ = [int(x) for x in pixels] # Seed random number generator for displacements if seed is not None: torch.manual_seed(seed) tiling_ = np.asarray(tiling[:2]) gsize = np.asarray(self.unitcell[:2]) * tiling_ psize = np.asarray(pixels_) pixperA = np.asarray(pixels_) / np.asarray(self.unitcell[:2]) / tiling_ # Get a list of unique atomic elements elements = list(set(np.asarray(self.atoms[:, 3], dtype=np.int))) # Get number of unique atomic elements nelements = len(elements) nsubslices = len(subslices) # Build list of equivalent sites if Fractional occupancy is to be # taken into account if fractional_occupancy and self.fractional_occupancy: equivalent_sites = find_equivalent_sites(self.atoms[:, :3], EPS=1e-3) # FDES method # Intialize potential array P = torch.zeros( np.prod([nelements, nsubslices, *pixels_, 2]), device=device, dtype=dtype ) # Construct a map of which atom corresponds to which slice islice = np.zeros((self.atoms.shape[0]), dtype=np.int) slice_stride = np.prod(pixels_) * 2 # if nsubslices > 1: # Finds which slice atom can be found in # WARNING Assumes that the slices list ends with 1.0 and is in # ascending order for i in range(nsubslices): zmin = 0 if i == 0 else subslices[i - 1] atoms_in_slice = (self.atoms[:, 2] % 1.0 >= zmin) & ( self.atoms[:, 2] % 1.0 < subslices[i] ) islice[atoms_in_slice] = i * slice_stride islice = torch.from_numpy(islice).type(torch.long).to(device) # else: # islice = 0 # Make map a pytorch Tensor # Construct a map of which atom corresponds to which element element_stride = nsubslices * slice_stride ielement = torch.tensor( [ element_stride * elements.index(int(self.atoms[iatom, 3])) for iatom in range(self.atoms.shape[0]) ], dtype=torch.long, device=device, ) if displacements: # Generate thermal displacements urms = torch.tensor( np.sqrt(self.atoms[:, 5])[:, np.newaxis] * pixperA[np.newaxis, :], dtype=P.dtype, device=device, ).view(self.atoms.shape[0], 2) # FDES algorithm implemented using the pytorch scatter_add function, # which takes a list of numbers and adds them to a corresponding list # of coordinates for tile in range(tiling[0] * tiling[1]): # For these atomic coordinates (in fractional coordinates) convert # to pixel coordinates posn = ( ( self.atoms[:, :2] + np.asarray([tile % tiling[0], tile // tiling[0]])[np.newaxis, :] ) / tiling_ * psize ) posn = torch.from_numpy(posn).to(device).type(P.dtype) if displacements: # Add displacement sampled from normal distribution to account # for atomic thermal motion disp = ( torch.randn(self.atoms.shape[0], 2, dtype=P.dtype, device=device) * urms ) # If using fractional occupancy force atoms occupying equivalent # sites to have the same displacement if fractional_occupancy and self.fractional_occupancy: disp = disp[equivalent_sites, :] posn[:, :2] += disp yc = ( torch.remainder(torch.ceil(posn[:, 0]).type(torch.long), pixels_[0]) * pixels_[1] * 2 ) yf = ( torch.remainder(torch.floor(posn[:, 0]).type(torch.long), pixels_[0]) * pixels_[1] * 2 ) xc = ( torch.remainder(torch.ceil(posn[:, 1]).type(torch.long), pixels_[1]) * 2 ) xf = ( torch.remainder(torch.floor(posn[:, 1]).type(torch.long), pixels_[1]) * 2 ) yh = torch.remainder(posn[:, 0], 1.0) yl = 1.0 - yh xh = torch.remainder(posn[:, 1], 1.0) xl = 1.0 - xh # Account for fractional occupancy of atomic sites if requested if fractional_occupancy and self.fractional_occupancy: xh *= torch.from_numpy(self.atoms[:, 4]).type(P.dtype).to(device) xl *= torch.from_numpy(self.atoms[:, 4]).type(P.dtype).to(device) # Each pixel is set to the overlap of a shifted rectangle in that pixel P.scatter_add_(0, ielement + islice + yc + xc, yh * xh) P.scatter_add_(0, ielement + islice + yc + xf, yh * xl) P.scatter_add_(0, ielement + islice + yf + xc, yl * xh) P.scatter_add_(0, ielement + islice + yf + xf, yl * xl) # Now view potential as a 4D array for next bit P = P.view(nelements, nsubslices, *pixels_, 2) # FFT potential to reciprocal space for i in range(P.shape[0]): for j in range(P.shape[1]): P[i, j] = torch.fft(P[i, j], signal_ndim=2) # Make sinc functions with appropriate singleton dimensions for pytorch # broadcasting /gridsize[0]*pixels_[0] /gridsize[1]*pixels_[1] sincy = ( sinc(torch.from_numpy(np.fft.fftfreq(pixels_[0]))) .view([1, 1, pixels_[0], 1, 1]) .to(device) .type(P.dtype) ) sincx = ( sinc(torch.from_numpy(np.fft.fftfreq(pixels_[1]))) .view([1, 1, 1, pixels_[1], 1]) .to(device) .type(P.dtype) ) # #Divide by sinc functions P /= sincy P /= sincx # Option to precalculate scattering factors and pass to program which # saves computation for if fe is None: fe_ = calculate_scattering_factors(psize, gsize, elements) else: fe_ = fe # Convolve with electron scattering factors using Fourier convolution theorem P *= torch.from_numpy(fe_).view(nelements, 1, *pixels_, 1).to(device) norm = np.prod(pixels_) / np.prod(self.unitcell[:2]) / np.prod(tiling) # Add atoms together P = norm * torch.sum(P, dim=0) # Only return real part return torch.ifft(P, signal_ndim=2)[..., 0] def make_transmission_functions( self, pixels, eV, subslices=[1.0], tiling=[1, 1], fe=None, displacements=True, fftout=True, dtype=None, device=None, fractional_occupancy=True, seed=None, bandwidth_limit=2 / 3, ): """ Make the transmission functions for the simulation object. Transmission functions are the exponential of the specimen electrostatic potential scaled by the interaction constant for electrons, sigma. These are used to model scattering by a thin slice of the object in the multislice algorithm Parameters: ----------- pixels : array_like Output pixel grid eV : float Probe accelerating voltage in electron-volts subslices : array_like, optional An array containing the depths at which each slice ends as a fraction of the simulation unit-cell, used for simulation objects thicker than typical multislice slicing (about 2 Angstrom) tiling : array_like,optional Repeat tiling of the simulation object fe: array_like,optional An array containing the electron scattering factors for the elements in the simulation object as calculated by the function calculate_scattering_factors """ # Make the specimen electrostatic potential T = self.make_potential( pixels, subslices, tiling, fe=fe, displacements=displacements, device=device, dtype=dtype, fractional_occupancy=fractional_occupancy, seed=seed, ) # Now take the complex exponential of the electrostatic potential # scaled by the electron interaction constant T = torch.fft(torch_c_exp(interaction_constant(eV) * T), signal_ndim=2) # Band-width limit the transmission function, see Earl Kirkland's book # for an discussion of why this is necessary for i in range(T.shape[0]): T[i] = bandwidth_limit_array(T[i], bandwidth_limit) if fftout: return torch.ifft(T, signal_ndim=2) return T def generate_slicing_figure(self, slices, show=True): """ Generate slicing figure. Generate a slicing figure that to aid in setting up the slicing of the sample for multislice algorithm. This will show where each of the slices end for a chosen slicing relative to the atoms. To minimize errors, the atoms should sit as close to the top of the slice as possible. Parameters ---------- slices: array_like, float An array containing the depths at which each slice ends as a fraction of the simulation unit-cell """ fig, ax = plt.subplots(ncols=2, figsize=(8, 4)) coords = self.atoms[:, :3] * self.unitcell[None, :] # Projection down the x-axis for i in range(2): ax[i].plot(coords[:, i], coords[:, 2], "bo", label="Atoms") for j, slice_ in enumerate(slices): if j == 0: label = "Slices" else: label = "_" ax[i].plot( [0, self.unitcell[i]], [slice_ * self.unitcell[2], slice_ * self.unitcell[2]], "r--", label=label, ) ax[i].set_xlim([0, self.unitcell[i]]) ax[i].set_xlabel(["y", "x"][i]) ax[i].set_ylim([self.unitcell[2], 0]) ax[i].set_ylabel("z") ax[i].set_title("View down {0} axis".format(["x", "y"][i])) ax[0].legend() if show: plt.show(block=True) return fig def rotate(self, theta, axis, origin=[0.5, 0.5, 0.5]): """ Rotate simulation object an amount an angle theta (in radians) about axis. Parameters ---------- theta: float Angle to rotate simulation object by in radians axis: array_like Axis about which to rotate simulation object eg [0,0,1] Keyword arguments ------------------ origin : array_like, optional Origin (in fractional coordinates) about which to rotate simulation object eg [0.5, 0.5, 0.5] """ new = copy.deepcopy(self) # Make rotation matrix, R, and the point about which we rotate, O R = rot_matrix(theta, axis) origin_ = np.asarray(origin) * self.unitcell # Get atomic coordinates in cartesian (not fractional coordinates) new.atoms[:, :3] = self.atoms[:, :3] * self.unitcell[np.newaxis, :] # Apply rotation matrix to each atom coordinate new.atoms[:, :3] = (new.atoms[:, :3] - origin_) @ R + origin_ # Apply rotation matrix to cell vertices vertices = ( np.asarray([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 1, 1]]) * self.unitcell - origin_ ) @ R + origin_ # Get new unit cell from maximum range of unit cell vertices origin_ = np.amin(vertices, axis=0) new.unitcell = np.ptp(vertices, axis=0) # Convert atoms back into fractional coordinates in new unit cell new.atoms[:, :3] = ((new.atoms[:, :3] - origin_) / new.unitcell) % 1.0 # Return rotated structure return new def rot90(self, k=1, axes=(0, 1)): """ Rotates a structure by 90 degrees in the plane specified by axes. Rotation direction is from the first towards the second axis. Parameters ---------- k : integer, optional Number of times the structure is rotated by 90 degrees. axes: (2,) array_like The array is rotated in the plane defined by the axes. Axes must be different. """ # Much of the following is adapted from the numpy.rot90 function axes = tuple(axes) if len(axes) != 2: raise ValueError("len(axes) must be 2.") k %= 4 if k == 0: # Do nothing return if k == 2: # Reflect in both axes self.reflect(axes) return axes_list = np.arange(0, 3) (axes_list[axes[0]], axes_list[axes[1]]) = ( axes_list[axes[1]], axes_list[axes[0]], ) if k == 1: self.reflect([axes[1]]) self.transpose(axes_list) else: # k == 3 self.transpose(axes_list) self.reflect([axes[1]]) return self def transpose(self, axes): """Transpose the axes of a simulation object.""" self.atoms[:, :3] = self.atoms[:, axes] self.unitcell = self.unitcell[axes] return self def tile(self, x=1, y=1, z=1): """Make a repeat unit tiling of the simulation object.""" # Make copy of original structure # new = copy.deepcopy(self) tiling = np.asarray([x, y, z], dtype=np.int) # Get atoms in unit cell natoms = self.atoms.shape[0] # Initialize new atom list newatoms = np.zeros((natoms * x * y * z, 6)) # Calculate new unit cell size self.unitcell = self.unitcell * np.asarray([x, y, z]) # tile out the integer amounts from itertools import product for j, k, l in product(*[np.arange(int(i)) for i in [x, y, z]]): # Calculate origin of this particular tile origin = np.asarray([j, k, l]) # Calculate index of this particular tile indx = j * int(y) * int(z) + k * int(z) + l # Add new atoms to unit cell newatoms[indx * natoms : (indx + 1) * natoms, :3] = ( self.atoms[:, :3] + origin[np.newaxis, :] ) / tiling[np.newaxis, :] # Copy other information about atoms newatoms[indx * natoms : (indx + 1) * natoms, 3:] = self.atoms[:, 3:] self.atoms = newatoms return self def concatenate(self, other, axis=2, side=1, eps=1e-2): """ Concatenate two simulation objects. Adds other simulation object to the current object. other is added to the bottom (top being z =0) routine will attempt to tile objects to match dimensions. Parameters: other : structure class Object that will be concatenated onto the other object. axis : int, optional Axis along which the two structures will be joined. side : int, optional Determines which side the other structure will be added onto the first structure. If side == 0 the structures will be added onto each other at the origin, if side == 1 the structures will be added onto each other at the end. eps : float, optional Fractional tolerance of the psuedo rational tiling to make the structure dimensions perpendicular to the beam direction match. """ # Make deep copies of the structure object and the slice this is so # that these objects remain untouched by the operation of this function new = copy.deepcopy(self) other_ = copy.deepcopy(other) tile1, tile2 = [np.ones(3, dtype=np.int) for i in range(2)] # Check if the two slices are the same size and # tile accordingly for ax in range(3): # If this axis is the concatenation axis, then it's not necessary # that the structures are the same size if ax == axis: continue # Calculate the psuedo-rational tiling if self.unitcell[ax] < other.unitcell[ax]: tile1[ax], tile2[ax] = psuedo_rational_tiling( self.unitcell[ax], other.unitcell[ax], eps ) else: tile2[ax], tile1[ax] = psuedo_rational_tiling( other.unitcell[ax], self.unitcell[ax], eps ) tile1[ax], tile2[ax] = psuedo_rational_tiling( self.unitcell[ax], other.unitcell[ax], eps ) new = new.tile(*tile1) tiled_zdim = new.unitcell[axis] other_ = other_.tile(*tile2) # Update the thickness of the resulting structure object. new.unitcell[axis] = tiled_zdim + other_.unitcell[axis] # Adjust fractional coordinates of atoms, multiply by old unitcell # size to transform into cartesian coordinates and then divide by # the old unitcell size to transform into fractional coordinates # in the new basis new.atoms[:, axis] *= tiled_zdim / new.unitcell[axis] other_.atoms[:, axis] *= other_.unitcell[axis] / new.unitcell[axis] # Adjust the coordinates of the new or old atoms depending on which # side the new structure is to be added. if side == 0: new.atoms[:, axis] += other_.unitcell[axis] / new.unitcell[axis] else: other_.atoms[:, axis] += self.unitcell[axis] / new.unitcell[axis] # Concatenate adjusted atomic coordinates new.atoms = np.concatenate([new.atoms, other_.atoms], axis=0) # Concatenate titles new.Title = self.Title + " and " + other.Title return new def reflect(self, axes): """Reflect structure in each of the axes enumerated in list axes.""" for ax in axes: self.atoms[:, ax] = (1 - self.atoms[:, ax]) % 1.0 return self def resize(self, fraction, axis): """ Resize (either crop or pad with vacuum) the simulation object. Resize the simulation object ranging such that the new axis runs from fraction[iax,0] to fraction[iax,1] on specified axis iax, slice_frac is in units of fractional coordinates. If fraction[iax,0] is < 0 then additional vacuum will be added, if > 0 then parts of the sample will be removed for axis[iax]. Likewise if fraction[iax,1] is > 1 then additional vacuum will be added, if < 1 then parts of the sample will be removed for axis[iax]. Parameters ---------- fraction : (nax,2) array_like Describes the size of the new simulation object as a fraction of old simulation object dimensions. axis : int or (nax,) array_like The axes of the simulation object that wil lbe resized Returns ------- New structure : pyms.structure object The resized structure object """ ax = ensure_array(axis) frac = ensure_array(fraction) if np.asarray(frac).ndim < 2: frac = [frac] # Work out which atoms will stay in the sliced structure mask = np.ones((self.atoms.shape[0],), dtype=np.bool) for a, f in zip(ax, frac): atomsin = np.logical_and(self.atoms[:, a] >= f[0], self.atoms[:, a] <= f[1]) mask = np.logical_and(atomsin, mask) # Make a copy of the structure new = copy.deepcopy(self) # Put remaining atoms back in new.atoms = self.atoms[mask, :] # Origin for atomic coordinates origin = np.zeros((3)) for a, f in zip(ax, frac): # Adjust unit cell dimensions new.unitcell[a] = (f[1] - f[0]) * self.unitcell[a] # Adjust origin of atomic coordinates origin[a] = f[0] new.atoms[:, :3] = (new.atoms[:, :3] - origin) * self.unitcell / new.unitcell # Return modified structure return new def cshift(self, shift, axis): """ Circular shift routine. Shift the atoms within the simulation cell an amount shift in fractional coordinates along specified axis (or axes if both shift and axis are array_like). Parameters ---------- shift : array_like or int Amount in fractional coordinates to shift (each) axis. axis : array_like or int Axis or list of axes to apply shift(s) to. """ def _cshift(atoms, x, ax): atoms[:, ax % 3] = np.mod(atoms[:, ax % 3] + x, 1.0) return atoms if hasattr(axis, "__len__"): for x, ax in zip(shift, axis): self.atoms = _cshift(self.atoms, x, ax) else: self.atoms = _cshift(self.atoms, shift, axis) return self
Static methods
def from_ase_cluster(asecell, occupancy=None, Title='', dwf=None)
-
Initialize from Atomic Simulation Environment (ASE) cluster object.
Expand source code
@classmethod def from_ase_cluster(cls, asecell, occupancy=None, Title="", dwf=None): """Initialize from Atomic Simulation Environment (ASE) cluster object.""" unitcell = asecell.cell[:] natoms = asecell.numbers.shape[0] atoms = np.concatenate( [ asecell.cell.scaled_positions(asecell.positions), asecell.numbers.reshape(natoms, 1), ], axis=1, ) if occupancy is None: occ = np.ones(natoms) if dwf is None: dwf = np.ones(natoms) * 3 / np.pi ** 2 / 8 return cls(unitcell, atoms, dwf, occ, Title)
def fromfile(fnam, temperature_factor_units='ums', atomic_coordinates='fractional', EPS=0.01, T=None)
-
Read in a simulation object from a structure file.
Appropriate structure files include *.p1 files, which is outputted by the vesta software:
K. Momma and F. Izumi, "VESTA 3 for three-dimensional visualization of crystal, volumetric and morphology data," J. Appl. Crystallogr., 44, 1272-1276 (2011).
or a *.xyz file in the standard of the prismatic software
Parameters
fnam
:string
- Filepath of the structure file
temperature_factor_units
:string
,optional- Units of the Debye-Waller temperature factors in the structure file appropriate inputs are B (crystallographic temperature factor), urms (root mean squared displacement) and ums (mean squared displacement, the default)
atomic_coordinates
:string
, optional- Units of the atomic coordinates can be "fractional" or "cartesian"
EPS
:float
,optional- Tolerance for procedures such as psuedo-rational tiling for non-orthorhombic crystal unit cells
T
:(3,3) array_like
orNone
- An optional transformation matrix to be applied to the unit cell and the atomic coordinates
Expand source code
@classmethod def fromfile( cls, fnam, temperature_factor_units="ums", atomic_coordinates="fractional", EPS=1e-2, T=None, ): """ Read in a simulation object from a structure file. Appropriate structure files include *.p1 files, which is outputted by the vesta software: K. Momma and F. Izumi, "VESTA 3 for three-dimensional visualization of crystal, volumetric and morphology data," J. Appl. Crystallogr., 44, 1272-1276 (2011). or a *.xyz file in the standard of the prismatic software Parameters ---------- fnam : string Filepath of the structure file temperature_factor_units : string,optional Units of the Debye-Waller temperature factors in the structure file appropriate inputs are B (crystallographic temperature factor), urms (root mean squared displacement) and ums (mean squared displacement, the default) atomic_coordinates : string, optional Units of the atomic coordinates can be "fractional" or "cartesian" EPS : float,optional Tolerance for procedures such as psuedo-rational tiling for non-orthorhombic crystal unit cells T : (3,3) array_like or None An optional transformation matrix to be applied to the unit cell and the atomic coordinates """ f = open(fnam, "r") ext = splitext(fnam)[1].lower() # Read title Title = f.readline().strip() if ext == ".p1": # I have no idea what the second line in the p1 file format means # so ignore it f.readline() # Get unit cell vector - WARNING assume an orthorhombic unit cell unitcell = np.loadtxt(f, max_rows=3, dtype=np.float) # Get the atomic symbol of each element atomtypes = np.loadtxt(f, max_rows=1, dtype=str, ndmin=1) # noqa # Get the number of atoms of each type natoms = np.loadtxt(f, max_rows=1, dtype=int, ndmin=1) # Skip empty line f.readline() # Total number of atoms totnatoms = np.sum(natoms) # Intialize array containing atomic information atoms = np.zeros((totnatoms, 6)) dwf = np.zeros((totnatoms,)) occ = np.zeros((totnatoms,)) for i in range(totnatoms): atominfo = split(r"\s+", f.readline().strip())[:6] # First three entries are the atomic coordinates atoms[i, :3] = np.asarray(atominfo[:3], dtype=np.float) # Fourth entry is the atomic symbol atoms[i, 3] = atomic_symbol.index( match("([A-Za-z]+)", atominfo[3]).group(0) ) # Final entries are the fractional occupancy and the temperature # (Debye-Waller) factor occ[i] = atominfo[4] dwf[i] = atominfo[5] elif ext == ".xyz": # Read in unit cell dimensions unitcell = np.asarray( [float(x) for x in split(r"\s+", f.readline().strip())[:3]] ) atoms = [] for line in f: # Look for end of file marker if line.strip() == "-1": break # Otherwise parse line atoms.append( np.array([float(x) for x in split(r"\s+", line.strip())[:6]]) ) # Now stack all atoms into numpy array atoms_ = np.stack(atoms, axis=0) # Rearrange columns of numpy array to match standard totnatoms = atoms_.shape[0] atoms = np.zeros((totnatoms, 4)) # Atomic coordinates atoms[:, :3] = atoms_[:, 1:4] # Atomic numbers (Z) atoms[:, 3] = atoms_[:, 0] # Fractional occupancy and Debye-Waller (temperature) factor dwf = atoms_[:, 5] occ = atoms_[:, 4] else: print("File extension: {0} not recognized".format(ext)) return None # Close file f.close() # If temperature factors are given in any other format than mean square # (ums) convert to mean square. Acceptable formats are crystallographic # temperature factor B and root mean square (urms) displacements if temperature_factor_units == "B": dwf *= 1 / (8 * np.pi ** 2) elif temperature_factor_units == "urms": dwf = dwf ** 2 elif temperature_factor_units == "ums": pass else: raise ValueError("Unrecognized temperature factor units") # If necessary, Convert atomic positions to fractional coordinates if atomic_coordinates == "cartesian": atoms[:, :3] /= unitcell[:3][np.newaxis, :] atoms[:, :3] = atoms[:, :3] % 1.0 if T is not None: # Transform atoms to cartesian basis and then apply transformation # matrix atoms[:, :3] = (T @ unitcell @ atoms[:, :3].T).T # Apply transformation matrix to unit-cell unitcell = unitcell @ T.T # Apply inverse of unit cell atoms[:, :3] = (np.linalg.inv(unitcell) @ atoms[:, :3].T).T return cls(unitcell, atoms[:, :4], dwf, occ, Title, EPS=EPS)
Methods
def concatenate(self, other, axis=2, side=1, eps=0.01)
-
Concatenate two simulation objects.
Adds other simulation object to the current object. other is added to the bottom (top being z =0) routine will attempt to tile objects to match dimensions.
Parameters: other : structure class Object that will be concatenated onto the other object. axis : int, optional Axis along which the two structures will be joined. side : int, optional Determines which side the other structure will be added onto the first structure. If side == 0 the structures will be added onto each other at the origin, if side == 1 the structures will be added onto each other at the end. eps : float, optional Fractional tolerance of the psuedo rational tiling to make the structure dimensions perpendicular to the beam direction match.
Expand source code
def concatenate(self, other, axis=2, side=1, eps=1e-2): """ Concatenate two simulation objects. Adds other simulation object to the current object. other is added to the bottom (top being z =0) routine will attempt to tile objects to match dimensions. Parameters: other : structure class Object that will be concatenated onto the other object. axis : int, optional Axis along which the two structures will be joined. side : int, optional Determines which side the other structure will be added onto the first structure. If side == 0 the structures will be added onto each other at the origin, if side == 1 the structures will be added onto each other at the end. eps : float, optional Fractional tolerance of the psuedo rational tiling to make the structure dimensions perpendicular to the beam direction match. """ # Make deep copies of the structure object and the slice this is so # that these objects remain untouched by the operation of this function new = copy.deepcopy(self) other_ = copy.deepcopy(other) tile1, tile2 = [np.ones(3, dtype=np.int) for i in range(2)] # Check if the two slices are the same size and # tile accordingly for ax in range(3): # If this axis is the concatenation axis, then it's not necessary # that the structures are the same size if ax == axis: continue # Calculate the psuedo-rational tiling if self.unitcell[ax] < other.unitcell[ax]: tile1[ax], tile2[ax] = psuedo_rational_tiling( self.unitcell[ax], other.unitcell[ax], eps ) else: tile2[ax], tile1[ax] = psuedo_rational_tiling( other.unitcell[ax], self.unitcell[ax], eps ) tile1[ax], tile2[ax] = psuedo_rational_tiling( self.unitcell[ax], other.unitcell[ax], eps ) new = new.tile(*tile1) tiled_zdim = new.unitcell[axis] other_ = other_.tile(*tile2) # Update the thickness of the resulting structure object. new.unitcell[axis] = tiled_zdim + other_.unitcell[axis] # Adjust fractional coordinates of atoms, multiply by old unitcell # size to transform into cartesian coordinates and then divide by # the old unitcell size to transform into fractional coordinates # in the new basis new.atoms[:, axis] *= tiled_zdim / new.unitcell[axis] other_.atoms[:, axis] *= other_.unitcell[axis] / new.unitcell[axis] # Adjust the coordinates of the new or old atoms depending on which # side the new structure is to be added. if side == 0: new.atoms[:, axis] += other_.unitcell[axis] / new.unitcell[axis] else: other_.atoms[:, axis] += self.unitcell[axis] / new.unitcell[axis] # Concatenate adjusted atomic coordinates new.atoms = np.concatenate([new.atoms, other_.atoms], axis=0) # Concatenate titles new.Title = self.Title + " and " + other.Title return new
def cshift(self, shift, axis)
-
Circular shift routine.
Shift the atoms within the simulation cell an amount shift in fractional coordinates along specified axis (or axes if both shift and axis are array_like).
Parameters
shift
:array_like
orint
- Amount in fractional coordinates to shift (each) axis.
axis
:array_like
orint
- Axis or list of axes to apply shift(s) to.
Expand source code
def cshift(self, shift, axis): """ Circular shift routine. Shift the atoms within the simulation cell an amount shift in fractional coordinates along specified axis (or axes if both shift and axis are array_like). Parameters ---------- shift : array_like or int Amount in fractional coordinates to shift (each) axis. axis : array_like or int Axis or list of axes to apply shift(s) to. """ def _cshift(atoms, x, ax): atoms[:, ax % 3] = np.mod(atoms[:, ax % 3] + x, 1.0) return atoms if hasattr(axis, "__len__"): for x, ax in zip(shift, axis): self.atoms = _cshift(self.atoms, x, ax) else: self.atoms = _cshift(self.atoms, shift, axis) return self
def generate_slicing_figure(self, slices, show=True)
-
Generate slicing figure.
Generate a slicing figure that to aid in setting up the slicing of the sample for multislice algorithm. This will show where each of the slices end for a chosen slicing relative to the atoms. To minimize errors, the atoms should sit as close to the top of the slice as possible.
Parameters
slices
:array_like, float
- An array containing the depths at which each slice ends as a fraction of the simulation unit-cell
Expand source code
def generate_slicing_figure(self, slices, show=True): """ Generate slicing figure. Generate a slicing figure that to aid in setting up the slicing of the sample for multislice algorithm. This will show where each of the slices end for a chosen slicing relative to the atoms. To minimize errors, the atoms should sit as close to the top of the slice as possible. Parameters ---------- slices: array_like, float An array containing the depths at which each slice ends as a fraction of the simulation unit-cell """ fig, ax = plt.subplots(ncols=2, figsize=(8, 4)) coords = self.atoms[:, :3] * self.unitcell[None, :] # Projection down the x-axis for i in range(2): ax[i].plot(coords[:, i], coords[:, 2], "bo", label="Atoms") for j, slice_ in enumerate(slices): if j == 0: label = "Slices" else: label = "_" ax[i].plot( [0, self.unitcell[i]], [slice_ * self.unitcell[2], slice_ * self.unitcell[2]], "r--", label=label, ) ax[i].set_xlim([0, self.unitcell[i]]) ax[i].set_xlabel(["y", "x"][i]) ax[i].set_ylim([self.unitcell[2], 0]) ax[i].set_ylabel("z") ax[i].set_title("View down {0} axis".format(["x", "y"][i])) ax[0].legend() if show: plt.show(block=True) return fig
def make_potential(self, pixels, subslices=[1.0], tiling=[1, 1], displacements=True, fractional_occupancy=True, fe=None, device=None, dtype=torch.float32, seed=None)
-
Generate the projected potential of the structure.
Calculate the projected electrostatic potential for a structure on a pixel grid with dimensions specified by pixels. Subslicing the unit cell is achieved by passing an array subslices that contains as its entries the depths at which each subslice should be terminated in units of fractional coordinates. Tiling of the unit cell (often necessary to make a sufficiently large simulation grid to fit the probe) is achieved by passing the tiling factors in the array tiling.
Parameters
pixels
:int, (2,) array_like
- The pixel size of the grid on which to calculate the projected potentials
subslices
:float, array_like
, optional- An array containing the depths at which each slice ends as a fraction of the simulation unit-cell
tiling
:int, (2,) array_like
, optional- Tiling of the simulation object (often necessary to make a sufficiently large simulation grid to fit the probe)
displacements
:bool
, optional- Pass displacements = False to turn off random displacements of the atoms due to thermal motion
fractional_occupancy
:bool
, optional- Pass fractional_occupancy = False to turn off fractional occupancy of atomic sites
fe
:float, array_like
- An array containing the electron scattering factors for the elements in the structure as calculated by the function calculate_scattering_factors, can be passed to save recalculating each time new potentials are generated
device
:torch.device
- Allows the user to control which device the calculations will occur on
dtype
:torch.dtype
- Controls the data-type of the output
seed
:int
- Seed for random number generator for atomic displacements.
Expand source code
def make_potential( self, pixels, subslices=[1.0], tiling=[1, 1], displacements=True, fractional_occupancy=True, fe=None, device=None, dtype=torch.float32, seed=None, ): """ Generate the projected potential of the structure. Calculate the projected electrostatic potential for a structure on a pixel grid with dimensions specified by pixels. Subslicing the unit cell is achieved by passing an array subslices that contains as its entries the depths at which each subslice should be terminated in units of fractional coordinates. Tiling of the unit cell (often necessary to make a sufficiently large simulation grid to fit the probe) is achieved by passing the tiling factors in the array tiling. Parameters ---------- pixels: int, (2,) array_like The pixel size of the grid on which to calculate the projected potentials subslices: float, array_like, optional An array containing the depths at which each slice ends as a fraction of the simulation unit-cell tiling: int, (2,) array_like, optional Tiling of the simulation object (often necessary to make a sufficiently large simulation grid to fit the probe) displacements: bool, optional Pass displacements = False to turn off random displacements of the atoms due to thermal motion fractional_occupancy: bool, optional Pass fractional_occupancy = False to turn off fractional occupancy of atomic sites fe: float, array_like An array containing the electron scattering factors for the elements in the structure as calculated by the function calculate_scattering_factors, can be passed to save recalculating each time new potentials are generated device: torch.device Allows the user to control which device the calculations will occur on dtype: torch.dtype Controls the data-type of the output seed: int Seed for random number generator for atomic displacements. """ # Initialize device cuda if available, CPU if no cuda is available device = get_device(device) # Ensure pixels is integer pixels_ = [int(x) for x in pixels] # Seed random number generator for displacements if seed is not None: torch.manual_seed(seed) tiling_ = np.asarray(tiling[:2]) gsize = np.asarray(self.unitcell[:2]) * tiling_ psize = np.asarray(pixels_) pixperA = np.asarray(pixels_) / np.asarray(self.unitcell[:2]) / tiling_ # Get a list of unique atomic elements elements = list(set(np.asarray(self.atoms[:, 3], dtype=np.int))) # Get number of unique atomic elements nelements = len(elements) nsubslices = len(subslices) # Build list of equivalent sites if Fractional occupancy is to be # taken into account if fractional_occupancy and self.fractional_occupancy: equivalent_sites = find_equivalent_sites(self.atoms[:, :3], EPS=1e-3) # FDES method # Intialize potential array P = torch.zeros( np.prod([nelements, nsubslices, *pixels_, 2]), device=device, dtype=dtype ) # Construct a map of which atom corresponds to which slice islice = np.zeros((self.atoms.shape[0]), dtype=np.int) slice_stride = np.prod(pixels_) * 2 # if nsubslices > 1: # Finds which slice atom can be found in # WARNING Assumes that the slices list ends with 1.0 and is in # ascending order for i in range(nsubslices): zmin = 0 if i == 0 else subslices[i - 1] atoms_in_slice = (self.atoms[:, 2] % 1.0 >= zmin) & ( self.atoms[:, 2] % 1.0 < subslices[i] ) islice[atoms_in_slice] = i * slice_stride islice = torch.from_numpy(islice).type(torch.long).to(device) # else: # islice = 0 # Make map a pytorch Tensor # Construct a map of which atom corresponds to which element element_stride = nsubslices * slice_stride ielement = torch.tensor( [ element_stride * elements.index(int(self.atoms[iatom, 3])) for iatom in range(self.atoms.shape[0]) ], dtype=torch.long, device=device, ) if displacements: # Generate thermal displacements urms = torch.tensor( np.sqrt(self.atoms[:, 5])[:, np.newaxis] * pixperA[np.newaxis, :], dtype=P.dtype, device=device, ).view(self.atoms.shape[0], 2) # FDES algorithm implemented using the pytorch scatter_add function, # which takes a list of numbers and adds them to a corresponding list # of coordinates for tile in range(tiling[0] * tiling[1]): # For these atomic coordinates (in fractional coordinates) convert # to pixel coordinates posn = ( ( self.atoms[:, :2] + np.asarray([tile % tiling[0], tile // tiling[0]])[np.newaxis, :] ) / tiling_ * psize ) posn = torch.from_numpy(posn).to(device).type(P.dtype) if displacements: # Add displacement sampled from normal distribution to account # for atomic thermal motion disp = ( torch.randn(self.atoms.shape[0], 2, dtype=P.dtype, device=device) * urms ) # If using fractional occupancy force atoms occupying equivalent # sites to have the same displacement if fractional_occupancy and self.fractional_occupancy: disp = disp[equivalent_sites, :] posn[:, :2] += disp yc = ( torch.remainder(torch.ceil(posn[:, 0]).type(torch.long), pixels_[0]) * pixels_[1] * 2 ) yf = ( torch.remainder(torch.floor(posn[:, 0]).type(torch.long), pixels_[0]) * pixels_[1] * 2 ) xc = ( torch.remainder(torch.ceil(posn[:, 1]).type(torch.long), pixels_[1]) * 2 ) xf = ( torch.remainder(torch.floor(posn[:, 1]).type(torch.long), pixels_[1]) * 2 ) yh = torch.remainder(posn[:, 0], 1.0) yl = 1.0 - yh xh = torch.remainder(posn[:, 1], 1.0) xl = 1.0 - xh # Account for fractional occupancy of atomic sites if requested if fractional_occupancy and self.fractional_occupancy: xh *= torch.from_numpy(self.atoms[:, 4]).type(P.dtype).to(device) xl *= torch.from_numpy(self.atoms[:, 4]).type(P.dtype).to(device) # Each pixel is set to the overlap of a shifted rectangle in that pixel P.scatter_add_(0, ielement + islice + yc + xc, yh * xh) P.scatter_add_(0, ielement + islice + yc + xf, yh * xl) P.scatter_add_(0, ielement + islice + yf + xc, yl * xh) P.scatter_add_(0, ielement + islice + yf + xf, yl * xl) # Now view potential as a 4D array for next bit P = P.view(nelements, nsubslices, *pixels_, 2) # FFT potential to reciprocal space for i in range(P.shape[0]): for j in range(P.shape[1]): P[i, j] = torch.fft(P[i, j], signal_ndim=2) # Make sinc functions with appropriate singleton dimensions for pytorch # broadcasting /gridsize[0]*pixels_[0] /gridsize[1]*pixels_[1] sincy = ( sinc(torch.from_numpy(np.fft.fftfreq(pixels_[0]))) .view([1, 1, pixels_[0], 1, 1]) .to(device) .type(P.dtype) ) sincx = ( sinc(torch.from_numpy(np.fft.fftfreq(pixels_[1]))) .view([1, 1, 1, pixels_[1], 1]) .to(device) .type(P.dtype) ) # #Divide by sinc functions P /= sincy P /= sincx # Option to precalculate scattering factors and pass to program which # saves computation for if fe is None: fe_ = calculate_scattering_factors(psize, gsize, elements) else: fe_ = fe # Convolve with electron scattering factors using Fourier convolution theorem P *= torch.from_numpy(fe_).view(nelements, 1, *pixels_, 1).to(device) norm = np.prod(pixels_) / np.prod(self.unitcell[:2]) / np.prod(tiling) # Add atoms together P = norm * torch.sum(P, dim=0) # Only return real part return torch.ifft(P, signal_ndim=2)[..., 0]
def make_transmission_functions(self, pixels, eV, subslices=[1.0], tiling=[1, 1], fe=None, displacements=True, fftout=True, dtype=None, device=None, fractional_occupancy=True, seed=None, bandwidth_limit=0.6666666666666666)
-
Make the transmission functions for the simulation object.
Transmission functions are the exponential of the specimen electrostatic potential scaled by the interaction constant for electrons, sigma. These are used to model scattering by a thin slice of the object in the multislice algorithm
Parameters:
pixels : array_like Output pixel grid eV : float Probe accelerating voltage in electron-volts subslices : array_like, optional An array containing the depths at which each slice ends as a fraction of the simulation unit-cell, used for simulation objects thicker than typical multislice slicing (about 2 Angstrom) tiling : array_like,optional Repeat tiling of the simulation object fe: array_like,optional An array containing the electron scattering factors for the elements in the simulation object as calculated by the function calculate_scattering_factors
Expand source code
def make_transmission_functions( self, pixels, eV, subslices=[1.0], tiling=[1, 1], fe=None, displacements=True, fftout=True, dtype=None, device=None, fractional_occupancy=True, seed=None, bandwidth_limit=2 / 3, ): """ Make the transmission functions for the simulation object. Transmission functions are the exponential of the specimen electrostatic potential scaled by the interaction constant for electrons, sigma. These are used to model scattering by a thin slice of the object in the multislice algorithm Parameters: ----------- pixels : array_like Output pixel grid eV : float Probe accelerating voltage in electron-volts subslices : array_like, optional An array containing the depths at which each slice ends as a fraction of the simulation unit-cell, used for simulation objects thicker than typical multislice slicing (about 2 Angstrom) tiling : array_like,optional Repeat tiling of the simulation object fe: array_like,optional An array containing the electron scattering factors for the elements in the simulation object as calculated by the function calculate_scattering_factors """ # Make the specimen electrostatic potential T = self.make_potential( pixels, subslices, tiling, fe=fe, displacements=displacements, device=device, dtype=dtype, fractional_occupancy=fractional_occupancy, seed=seed, ) # Now take the complex exponential of the electrostatic potential # scaled by the electron interaction constant T = torch.fft(torch_c_exp(interaction_constant(eV) * T), signal_ndim=2) # Band-width limit the transmission function, see Earl Kirkland's book # for an discussion of why this is necessary for i in range(T.shape[0]): T[i] = bandwidth_limit_array(T[i], bandwidth_limit) if fftout: return torch.ifft(T, signal_ndim=2) return T
def orthorhombic_supercell(self, EPS=0.01)
-
Create an orthorhombic supercell from a monoclinic crystal unit cell.
If not orthorhombic attempt psuedo rational tiling of general monoclinic structure. Assumes that the self.unitcell matrix is lower triangular.
Expand source code
def orthorhombic_supercell(self, EPS=1e-2): """ Create an orthorhombic supercell from a monoclinic crystal unit cell. If not orthorhombic attempt psuedo rational tiling of general monoclinic structure. Assumes that the self.unitcell matrix is lower triangular. """ if not np.abs(np.dot(self.unitcell[0], self.unitcell[1])) < EPS: tiley, tilex = psuedo_rational_tiling(*self.unitcell[0:2, 0], EPS) # Make deepcopy of old unit cell olduc = copy.deepcopy(self.unitcell) # Tile out atoms self.tile(tiley, tilex, 1) # Calculate size of old unit cell under tiling olduc = np.asarray([tiley, tilex, 1])[:, np.newaxis] * olduc self.unitcell = copy.deepcopy(olduc) self.unitcell[1, 0] = 0.0 # Now calculate fractional coordinates in new orthorhombic cell self.atoms[:, :3] = change_of_basis(self.atoms[:, :3], self.unitcell, olduc) else: self.unitcell[0, 1:] = 0.0 self.unitcell[1, ::2] = 0.0 # Now tile crystal in x and y tilez1, tiley = psuedo_rational_tiling(*self.unitcell[::-2, 0], EPS) tilez2, tilex = psuedo_rational_tiling(*self.unitcell[3:0:-1, 1], EPS) tilez = remove_common_factors([tilez1, tilez2, tilez1 * tilez2])[-1] tiley *= tilez // tilez1 tilex *= tilez // tilez2 olduc = copy.deepcopy(self.unitcell) # Tile out atoms self.tile(tiley, tilex, tilez) # Calculate size of old unit cell under tiling olduc = np.asarray([tiley, tilex, tilez])[:, np.newaxis] * olduc self.unitcell = copy.deepcopy(olduc) self.unitcell[2, 0:2] = 0.0 # Now calculate fractional coordinates in new orthorhombic cell self.atoms[:, :3] = np.mod( self.atoms[:, :3] @ olduc @ np.linalg.inv(self.unitcell), 1.0 ) self.unitcell = np.diag(self.unitcell) # Check for negative values of self.unitcell and rectify for i in range(3): if self.unitcell[i] < 0: self.atoms[:, i] = (1.0 - self.atoms[:, i]) % 1.0 self.unitcell = np.abs(self.unitcell)
def output_vesta_xtl(self, fnam)
-
Output an .xtl file which is viewable by the vesta software.
See K. Momma and F. Izumi, "VESTA 3 for three-dimensional visualization of crystal, volumetric and morphology data," J. Appl. Crystallogr., 44, 1272-1276 (2011).
Warning: Vesta xtl files do not contain fractional occupancy information
Expand source code
def output_vesta_xtl(self, fnam): """Output an .xtl file which is viewable by the vesta software. See K. Momma and F. Izumi, "VESTA 3 for three-dimensional visualization of crystal, volumetric and morphology data," J. Appl. Crystallogr., 44, 1272-1276 (2011). Warning: Vesta xtl files do not contain fractional occupancy information """ f = open(splitext(fnam)[0] + ".xtl", "w") f.write("TITLE " + self.Title + "\n CELL \n") f.write(" {0:.5f} {1:.5f} {2:.5f} 90 90 90\n".format(*self.unitcell)) f.write("SYMMETRY NUMBER 1\n SYMMETRY LABEL P1\n ATOMS \n") f.write("NAME X Y Z" + "\n") for i in range(self.atoms.shape[0]): f.write( "{0} {1:.4f} {2:.4f} {3:.4f}\n".format( atomic_symbol[int(self.atoms[i, 3])], *self.atoms[i, :3] ) ) f.write("EOF") f.close()
def output_xyz(self, fnam, atomic_coordinates='cartesian', temperature_factor_units='sqrturms')
-
Output an .xyz structure file.
This is the input format used by Kirkland's EM codes and the prismatic software.
Expand source code
def output_xyz( self, fnam, atomic_coordinates="cartesian", temperature_factor_units="sqrturms" ): """ Output an .xyz structure file. This is the input format used by Kirkland's EM codes and the prismatic software. """ f = open(splitext(fnam)[0] + ".xyz", "w") f.write(self.Title + "\n {0:.4f} {1:.4f} {2:.4f}\n".format(*self.unitcell)) if atomic_coordinates == "cartesian": coords = self.atoms[:, :3] * self.unitcell else: coords = self.atoms[:, :3] # If temperature factors are given as B then convert to urms if temperature_factor_units == "B": DWFs = self.atoms[:, 5] * 8 * np.pi ** 2 elif temperature_factor_units == "sqrturms": DWFs = np.sqrt(self.atoms[:, 5]) for coord, atom, DWF in zip(coords, self.atoms, DWFs): f.write( "{0:d} {1:.4f} {2:.4f} {3:.4f} {4:.2f} {5:.3f}\n".format( int(atom[3]), *coord, atom[4], DWF ) ) f.write("-1") f.close()
def quickplot(self, atomscale=None, cmap=<matplotlib.colors.ListedColormap object>, block=True, colors=None)
-
Make a quick 3D scatter plot of the atomic sites within the structure.
For more detailed visualization output the structure file to a file format readable by the Vesta software using output_vesta_xtl
Expand source code
def quickplot( self, atomscale=None, cmap=plt.get_cmap("Dark2"), block=True, colors=None ): """ Make a quick 3D scatter plot of the atomic sites within the structure. For more detailed visualization output the structure file to a file format readable by the Vesta software using output_vesta_xtl """ from mpl_toolkits.mplot3d import Axes3D # NOQA if atomscale is None: atomscale = 1e-3 * np.amax(self.unitcell) fig = plt.figure() ax = fig.add_subplot(111, projection="3d") if colors is None: colors = cmap(self.atoms[:, 3] / np.amax(self.atoms[:, 3])) sizes = self.atoms[:, 3] * atomscale ax.scatter( *[self.atoms[:, i] * self.unitcell[i] for i in [1, 0, 2]], c=colors, s=sizes ) ax.set_xlim3d(0.0, self.unitcell[1]) ax.set_ylim3d(top=0.0, bottom=self.unitcell[0]) ax.set_zlim3d(top=0.0, bottom=self.unitcell[2]) ax.set_xlabel("x") ax.set_ylabel("y") ax.set_zlabel("z") plt.show(block=block) return fig
def reflect(self, axes)
-
Reflect structure in each of the axes enumerated in list axes.
Expand source code
def reflect(self, axes): """Reflect structure in each of the axes enumerated in list axes.""" for ax in axes: self.atoms[:, ax] = (1 - self.atoms[:, ax]) % 1.0 return self
def resize(self, fraction, axis)
-
Resize (either crop or pad with vacuum) the simulation object.
Resize the simulation object ranging such that the new axis runs from fraction[iax,0] to fraction[iax,1] on specified axis iax, slice_frac is in units of fractional coordinates. If fraction[iax,0] is < 0 then additional vacuum will be added, if > 0 then parts of the sample will be removed for axis[iax]. Likewise if fraction[iax,1] is > 1 then additional vacuum will be added, if < 1 then parts of the sample will be removed for axis[iax].
Parameters
fraction
:(nax,2) array_like
- Describes the size of the new simulation object as a fraction of old simulation object dimensions.
axis
:int
or(nax,) array_like
- The axes of the simulation object that wil lbe resized
Returns
New structure : pyms.structure object
- The resized structure object
Expand source code
def resize(self, fraction, axis): """ Resize (either crop or pad with vacuum) the simulation object. Resize the simulation object ranging such that the new axis runs from fraction[iax,0] to fraction[iax,1] on specified axis iax, slice_frac is in units of fractional coordinates. If fraction[iax,0] is < 0 then additional vacuum will be added, if > 0 then parts of the sample will be removed for axis[iax]. Likewise if fraction[iax,1] is > 1 then additional vacuum will be added, if < 1 then parts of the sample will be removed for axis[iax]. Parameters ---------- fraction : (nax,2) array_like Describes the size of the new simulation object as a fraction of old simulation object dimensions. axis : int or (nax,) array_like The axes of the simulation object that wil lbe resized Returns ------- New structure : pyms.structure object The resized structure object """ ax = ensure_array(axis) frac = ensure_array(fraction) if np.asarray(frac).ndim < 2: frac = [frac] # Work out which atoms will stay in the sliced structure mask = np.ones((self.atoms.shape[0],), dtype=np.bool) for a, f in zip(ax, frac): atomsin = np.logical_and(self.atoms[:, a] >= f[0], self.atoms[:, a] <= f[1]) mask = np.logical_and(atomsin, mask) # Make a copy of the structure new = copy.deepcopy(self) # Put remaining atoms back in new.atoms = self.atoms[mask, :] # Origin for atomic coordinates origin = np.zeros((3)) for a, f in zip(ax, frac): # Adjust unit cell dimensions new.unitcell[a] = (f[1] - f[0]) * self.unitcell[a] # Adjust origin of atomic coordinates origin[a] = f[0] new.atoms[:, :3] = (new.atoms[:, :3] - origin) * self.unitcell / new.unitcell # Return modified structure return new
def rot90(self, k=1, axes=(0, 1))
-
Rotates a structure by 90 degrees in the plane specified by axes.
Rotation direction is from the first towards the second axis.
Parameters
k
:integer
, optional- Number of times the structure is rotated by 90 degrees.
axes
:(2,) array_like
- The array is rotated in the plane defined by the axes. Axes must be different.
Expand source code
def rot90(self, k=1, axes=(0, 1)): """ Rotates a structure by 90 degrees in the plane specified by axes. Rotation direction is from the first towards the second axis. Parameters ---------- k : integer, optional Number of times the structure is rotated by 90 degrees. axes: (2,) array_like The array is rotated in the plane defined by the axes. Axes must be different. """ # Much of the following is adapted from the numpy.rot90 function axes = tuple(axes) if len(axes) != 2: raise ValueError("len(axes) must be 2.") k %= 4 if k == 0: # Do nothing return if k == 2: # Reflect in both axes self.reflect(axes) return axes_list = np.arange(0, 3) (axes_list[axes[0]], axes_list[axes[1]]) = ( axes_list[axes[1]], axes_list[axes[0]], ) if k == 1: self.reflect([axes[1]]) self.transpose(axes_list) else: # k == 3 self.transpose(axes_list) self.reflect([axes[1]]) return self
def rotate(self, theta, axis, origin=[0.5, 0.5, 0.5])
-
Rotate simulation object an amount an angle theta (in radians) about axis.
Parameters
theta
:float
- Angle to rotate simulation object by in radians
axis
:array_like
- Axis about which to rotate simulation object eg [0,0,1]
Keyword Arguments
origin : array_like, optional Origin (in fractional coordinates) about which to rotate simulation object eg [0.5, 0.5, 0.5]
Expand source code
def rotate(self, theta, axis, origin=[0.5, 0.5, 0.5]): """ Rotate simulation object an amount an angle theta (in radians) about axis. Parameters ---------- theta: float Angle to rotate simulation object by in radians axis: array_like Axis about which to rotate simulation object eg [0,0,1] Keyword arguments ------------------ origin : array_like, optional Origin (in fractional coordinates) about which to rotate simulation object eg [0.5, 0.5, 0.5] """ new = copy.deepcopy(self) # Make rotation matrix, R, and the point about which we rotate, O R = rot_matrix(theta, axis) origin_ = np.asarray(origin) * self.unitcell # Get atomic coordinates in cartesian (not fractional coordinates) new.atoms[:, :3] = self.atoms[:, :3] * self.unitcell[np.newaxis, :] # Apply rotation matrix to each atom coordinate new.atoms[:, :3] = (new.atoms[:, :3] - origin_) @ R + origin_ # Apply rotation matrix to cell vertices vertices = ( np.asarray([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 1, 1]]) * self.unitcell - origin_ ) @ R + origin_ # Get new unit cell from maximum range of unit cell vertices origin_ = np.amin(vertices, axis=0) new.unitcell = np.ptp(vertices, axis=0) # Convert atoms back into fractional coordinates in new unit cell new.atoms[:, :3] = ((new.atoms[:, :3] - origin_) / new.unitcell) % 1.0 # Return rotated structure return new
def tile(self, x=1, y=1, z=1)
-
Make a repeat unit tiling of the simulation object.
Expand source code
def tile(self, x=1, y=1, z=1): """Make a repeat unit tiling of the simulation object.""" # Make copy of original structure # new = copy.deepcopy(self) tiling = np.asarray([x, y, z], dtype=np.int) # Get atoms in unit cell natoms = self.atoms.shape[0] # Initialize new atom list newatoms = np.zeros((natoms * x * y * z, 6)) # Calculate new unit cell size self.unitcell = self.unitcell * np.asarray([x, y, z]) # tile out the integer amounts from itertools import product for j, k, l in product(*[np.arange(int(i)) for i in [x, y, z]]): # Calculate origin of this particular tile origin = np.asarray([j, k, l]) # Calculate index of this particular tile indx = j * int(y) * int(z) + k * int(z) + l # Add new atoms to unit cell newatoms[indx * natoms : (indx + 1) * natoms, :3] = ( self.atoms[:, :3] + origin[np.newaxis, :] ) / tiling[np.newaxis, :] # Copy other information about atoms newatoms[indx * natoms : (indx + 1) * natoms, 3:] = self.atoms[:, 3:] self.atoms = newatoms return self
def to_ase_atoms(self)
-
Convert structure to Atomic Simulation Environment (ASE) atoms object.
Expand source code
def to_ase_atoms(self): """Convert structure to Atomic Simulation Environment (ASE) atoms object.""" scaled_positions = self.atoms[:, :3] numbers = self.atoms[:, 3].astype(np.int) cell = self.unitcell pbc = [True, True, True] return ase.Atoms( scaled_positions=scaled_positions, numbers=numbers, cell=cell, pbc=pbc )
def transpose(self, axes)
-
Transpose the axes of a simulation object.
Expand source code
def transpose(self, axes): """Transpose the axes of a simulation object.""" self.atoms[:, :3] = self.atoms[:, axes] self.unitcell = self.unitcell[axes] return self